2 Commits

Author SHA1 Message Date
syntaxbullet
5ff3fa9ab5 feat: Implement a sequential test runner script and integrate it into the deploy workflow.
Some checks failed
Deploy to Production / test (push) Failing after 23s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 15:34:59 +01:00
syntaxbullet
c8bf69a969 Remove the admin update service, command, and related files, and update Docker configurations. 2026-01-30 15:29:50 +01:00
10 changed files with 42 additions and 1677 deletions

View File

@@ -31,7 +31,7 @@ jobs:
run: bun install --frozen-lockfile
- name: Run Tests
run: bun test
run: bash shared/scripts/test-sequential.sh
# ==========================================================================
# Build Job

View File

@@ -30,17 +30,7 @@ WORKDIR /app
# Create non-root user for security (bun user already exists with 1000:1000)
# No need to create user/group
# Install runtime dependencies for update/deploy commands
RUN apt-get update && apt-get install -y \
git \
curl \
gnupg \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system --add safe.directory /app/deploy
# Copy only what's needed for production
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules

View File

@@ -1,247 +0,0 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { UpdateService } from "@shared/modules/admin/update.service";
import {
getCheckingEmbed,
getNoUpdatesEmbed,
getUpdatesAvailableMessage,
getPreparingEmbed,
getUpdatingEmbed,
getCancelledEmbed,
getTimeoutEmbed,
getErrorEmbed,
getRollbackSuccessEmbed,
getRollbackFailedEmbed,
getDeployNotAvailableEmbed,
getDeployCheckingEmbed,
getDeployProgressEmbed,
getDeploySuccessEmbed,
getDeployErrorEmbed
} from "@/modules/admin/update.view";
export const update = createCommand({
data: new SlashCommandBuilder()
.setName("update")
.setDescription("Check for updates and restart the bot")
.addSubcommand(sub =>
sub.setName("check")
.setDescription("Check for and apply available updates")
.addBooleanOption(option =>
option.setName("force")
.setDescription("Force update even if no changes detected")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("rollback")
.setDescription("Rollback to the previous version")
)
.addSubcommand(sub =>
sub.setName("deploy")
.setDescription("Pull latest code and rebuild container (production only)")
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "rollback") {
await handleRollback(interaction);
} else if (subcommand === "deploy") {
await handleDeploy(interaction);
} else {
await handleUpdate(interaction);
}
}
});
async function handleUpdate(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const force = interaction.options.getBoolean("force") || false;
try {
// 1. Check for updates (now includes requirements in one call)
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
if (!updateInfo.hasUpdates && !force) {
await interaction.editReply({
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
});
return;
}
// 2. Extract requirements from the combined response
const { requirements } = updateInfo;
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
// 3. Show confirmation with details
const { embeds, components } = getUpdatesAvailableMessage(
updateInfo,
requirements,
categories,
force
);
const response = await interaction.editReply({ embeds, components });
// 4. Wait for confirmation
try {
const confirmation = await response.awaitMessageComponent({
filter: (i: any) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "confirm_update") {
await confirmation.update({
embeds: [getPreparingEmbed()],
components: []
});
// 5. Save rollback point
const previousCommit = await UpdateService.saveRollbackPoint();
// 6. Prepare restart context
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: requirements.needsMigrations,
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
buildWebAssets: requirements.needsWebBuild,
previousCommit: previousCommit.substring(0, 7),
newCommit: updateInfo.latestCommit
});
// 7. Show updating status
await interaction.editReply({
embeds: [getUpdatingEmbed(requirements)]
});
// 8. Perform update
await UpdateService.performUpdate(updateInfo.branch);
// 9. Trigger restart
await UpdateService.triggerRestart();
} else {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
}
} catch (e) {
if (e instanceof Error && e.message.includes("time")) {
await interaction.editReply({
embeds: [getTimeoutEmbed()],
components: []
});
} else {
throw e;
}
}
} catch (error) {
console.error("Update failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)],
components: []
});
}
}
async function handleRollback(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const hasRollback = await UpdateService.hasRollbackPoint();
if (!hasRollback) {
await interaction.editReply({
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
});
return;
}
const result = await UpdateService.rollback();
if (result.success) {
await interaction.editReply({
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
});
// Restart after rollback
setTimeout(() => UpdateService.triggerRestart(), 1000);
} else {
await interaction.editReply({
embeds: [getRollbackFailedEmbed(result.message)]
});
}
} catch (error) {
console.error("Rollback failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)]
});
}
}
async function handleDeploy(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
// Check if deploy is available
const available = await UpdateService.isDeployAvailable();
if (!available) {
await interaction.editReply({
embeds: [getDeployNotAvailableEmbed()]
});
return;
}
// Show checking status
await interaction.editReply({
embeds: [getDeployCheckingEmbed()]
});
// Perform deployment with progress updates
const result = await UpdateService.performDeploy((step) => {
interaction.editReply({
embeds: [getDeployProgressEmbed(step)]
});
});
if (result.success) {
// Save restart context so we can notify on startup
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: true, // Always check migrations on deploy
installDependencies: false, // Handled by Docker build
buildWebAssets: false, // Handled by Docker build
previousCommit: result.previousCommit,
newCommit: result.newCommit
});
await interaction.editReply({
embeds: [getDeploySuccessEmbed(
result.previousCommit,
result.newCommit,
result.output
)]
});
} else {
await interaction.editReply({
embeds: [getDeployErrorEmbed(result.error || "Unknown error")]
});
}
} catch (error) {
console.error("Deploy failed:", error);
await interaction.editReply({
embeds: [getDeployErrorEmbed(error instanceof Error ? error.message : String(error))]
});
}
}

View File

@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
console.log(`Ready! Logged in as ${c.user.tag}`);
schedulerService.start();
// Handle post-update tasks
const { UpdateService } = await import("@shared/modules/admin/update.service");
await UpdateService.handlePostRestart(c);
},
};

View File

@@ -1,35 +0,0 @@
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
buildWebAssets: boolean;
previousCommit: string;
newCommit: string;
}
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsWebBuild: boolean;
needsMigrations: boolean;
changedFiles: string[];
error?: Error;
}
export interface UpdateInfo {
hasUpdates: boolean;
branch: string;
currentCommit: string;
latestCommit: string;
commitCount: number;
commits: CommitInfo[];
}
export interface CommitInfo {
hash: string;
message: string;
author: string;
}

View File

@@ -1,419 +0,0 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
// Constants for UI
const LOG_TRUNCATE_LENGTH = 800;
const OUTPUT_TRUNCATE_LENGTH = 400;
function truncate(text: string, maxLength: number): string {
if (!text) return "";
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
// ============ Pre-Update Embeds ============
export function getCheckingEmbed() {
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
}
export function getNoUpdatesEmbed(currentCommit: string) {
return createSuccessEmbed(
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
"✅ Already Up to Date"
);
}
export function getUpdatesAvailableMessage(
updateInfo: UpdateInfo,
requirements: UpdateCheckResult,
changeCategories: Record<string, number>,
force: boolean
) {
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements;
// Build commit list (max 5)
const commitList = commits
.slice(0, 5)
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
.join("\n");
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
// Build change categories
const categoryList = Object.entries(changeCategories)
.map(([cat, count]) => `${cat}: ${count} file${count > 1 ? "s" : ""}`)
.join("\n");
// Build requirements list
const reqs: string[] = [];
if (needsRootInstall) reqs.push("📦 Install root dependencies");
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
if (needsWebBuild) reqs.push("🏗️ Build web dashboard");
if (needsMigrations) reqs.push("🗃️ Run database migrations");
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
const embed = new EmbedBuilder()
.setTitle("📥 Updates Available")
.setColor(force ? 0xFF6B6B : 0x5865F2)
.addFields(
{
name: "Version",
value: `\`${currentCommit}\`\`${latestCommit}\``,
inline: true
},
{
name: "Branch",
value: `\`${branch}\``,
inline: true
},
{
name: "Commits",
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
inline: true
},
{
name: "Recent Changes",
value: commitList + moreCommits || "No commits",
inline: false
},
{
name: "Files Changed",
value: categoryList || "Unknown",
inline: true
},
{
name: "Update Actions",
value: reqs.join("\n"),
inline: true
}
)
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_update")
.setLabel(force ? "Force Update" : "Update Now")
.setEmoji(force ? "⚠️" : "🚀")
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_update")
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
return { embeds: [embed], components: [row] };
}
// ============ Update Progress Embeds ============
export function getPreparingEmbed() {
return createInfoEmbed(
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
"⏳ Preparing Update"
);
}
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
const steps: string[] = ["✅ Rollback point saved"];
steps.push("📥 Downloading updates...");
if (requirements.needsRootInstall || requirements.needsWebInstall) {
steps.push("📦 Dependencies will be installed after restart");
}
if (requirements.needsWebBuild) {
steps.push("🏗️ Web dashboard will be rebuilt after restart");
}
if (requirements.needsMigrations) {
steps.push("🗃️ Migrations will run after restart");
}
steps.push("\n🔄 **Restarting now...**");
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
}
export function getCancelledEmbed() {
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
}
export function getTimeoutEmbed() {
return createWarningEmbed(
"No response received within 30 seconds.\nRun `/update` again when ready.",
"⏰ Timed Out"
);
}
export function getErrorEmbed(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return createErrorEmbed(
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
"❌ Update Failed"
);
}
// ============ Post-Restart Embeds ============
export interface PostRestartResult {
installSuccess: boolean;
installOutput: string;
webBuildSuccess: boolean;
webBuildOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranWebBuild: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess;
const embed = new EmbedBuilder()
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
.setTimestamp();
// Version info
if (result.previousCommit && result.newCommit) {
embed.addFields({
name: "Version",
value: `\`${result.previousCommit}\`\`${result.newCommit}\``,
inline: false
});
}
// Results summary
const results: string[] = [];
if (result.ranInstall) {
results.push(result.installSuccess
? "✅ Dependencies installed"
: "❌ Dependency installation failed"
);
}
if (result.ranWebBuild) {
results.push(result.webBuildSuccess
? "✅ Web dashboard built"
: "❌ Web dashboard build failed"
);
}
if (result.ranMigrations) {
results.push(result.migrationSuccess
? "✅ Migrations applied"
: "❌ Migration failed"
);
}
if (results.length > 0) {
embed.addFields({
name: "Actions Performed",
value: results.join("\n"),
inline: false
});
}
// Output details (collapsed if too long)
if (result.installOutput && !result.installSuccess) {
embed.addFields({
name: "Install Output",
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
if (result.webBuildOutput && !result.webBuildSuccess) {
embed.addFields({
name: "Web Build Output",
value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
if (result.migrationOutput && !result.migrationSuccess) {
embed.addFields({
name: "Migration Output",
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
// Footer with rollback hint
if (!isSuccess && hasRollback) {
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
}
// Build components
const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (!isSuccess && hasRollback) {
const rollbackButton = new ButtonBuilder()
.setCustomId("rollback_update")
.setLabel("Rollback")
.setEmoji("↩️")
.setStyle(ButtonStyle.Danger);
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
}
return { embeds: [embed], components };
}
export function getInstallingDependenciesEmbed() {
return createInfoEmbed(
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
"⏳ Installing Dependencies"
);
}
export function getRunningMigrationsEmbed() {
return createInfoEmbed(
"🗃️ Applying database migrations...",
"⏳ Running Migrations"
);
}
export function getBuildingWebEmbed() {
return createInfoEmbed(
"🌐 Building web dashboard assets...\nThis may take a moment.",
"⏳ Building Web Dashboard"
);
}
export interface PostRestartProgress {
installDeps: boolean;
buildWeb: boolean;
runMigrations: boolean;
currentStep: "starting" | "install" | "build" | "migrate" | "done";
installDone?: boolean;
buildDone?: boolean;
migrateDone?: boolean;
}
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
const steps: string[] = [];
// Installation step
if (progress.installDeps) {
if (progress.currentStep === "install") {
steps.push("⏳ Installing dependencies...");
} else if (progress.installDone) {
steps.push("✅ Dependencies installed");
} else {
steps.push("⬚ Install dependencies");
}
}
// Web build step
if (progress.buildWeb) {
if (progress.currentStep === "build") {
steps.push("⏳ Building web dashboard...");
} else if (progress.buildDone) {
steps.push("✅ Web dashboard built");
} else {
steps.push("⬚ Build web dashboard");
}
}
// Migrations step
if (progress.runMigrations) {
if (progress.currentStep === "migrate") {
steps.push("⏳ Running migrations...");
} else if (progress.migrateDone) {
steps.push("✅ Migrations applied");
} else {
steps.push("⬚ Run migrations");
}
}
if (steps.length === 0) {
steps.push("⚡ Quick restart (no extra steps needed)");
}
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
}
export function getRollbackSuccessEmbed(commit: string) {
return createSuccessEmbed(
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
"↩️ Rollback Complete"
);
}
export function getRollbackFailedEmbed(error: string) {
return createErrorEmbed(
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
"❌ Rollback Failed"
);
}
// ============ Deploy Embeds ============
export function getDeployNotAvailableEmbed() {
return createErrorEmbed(
"Docker is not available in this environment.\n\n" +
"Deploy via Discord requires Docker socket access. " +
"Use the deploy script manually:\n" +
"```bash\ncd ~/Aurora && bash shared/scripts/deploy.sh\n```",
"❌ Deploy Unavailable"
);
}
export function getDeployCheckingEmbed() {
return createInfoEmbed(
"🔍 Checking for updates and preparing deployment...",
"🚀 Deploy"
);
}
export function getDeployProgressEmbed(step: string) {
return createInfoEmbed(
step,
"🚀 Deploying"
);
}
export function getDeploySuccessEmbed(previousCommit: string, newCommit: string, output: string) {
const embed = new EmbedBuilder()
.setTitle("🚀 Deployment Triggered")
.setColor(0x57F287)
.addFields(
{
name: "Version",
value: `\`${previousCommit}\`\`${newCommit}\``,
inline: false
},
{
name: "Status",
value: output || "Container rebuilding...",
inline: false
}
)
.setFooter({ text: "Container will restart shortly" })
.setTimestamp();
return embed;
}
export function getDeployNoChangesEmbed(currentCommit: string) {
return createSuccessEmbed(
`Already running the latest version.\n\n**Current:** \`${currentCommit}\``,
"✅ Up to Date"
);
}
export function getDeployErrorEmbed(error: string) {
return createErrorEmbed(
`Deployment failed:\n\`\`\`\n${truncate(error, 500)}\n\`\`\``,
"❌ Deploy Failed"
);
}

View File

@@ -29,21 +29,6 @@ services:
limits:
memory: 512M
socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: socket_proxy
restart: unless-stopped
environment:
- CONTAINERS=1
- POST=1
- BUILD=1
- NETWORKS=1
- IMAGES=1 # Needed for pulling/pruning
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
app:
container_name: aurora_app
restart: unless-stopped
@@ -54,13 +39,7 @@ services:
image: aurora-app:latest
ports:
- "127.0.0.1:3000:3000"
# Volumes for bot-triggered deployments
volumes:
# Project directory - allows git pull and rebuild
- .:/app/deploy
# SSH Keys for git authentication
- ~/.ssh/aurora_bot_key:/home/bun/.ssh/id_ed25519:ro
- ~/.ssh/known_hosts:/home/bun/.ssh/known_hosts:ro
working_dir: /app
environment:
- NODE_ENV=production
@@ -74,14 +53,11 @@ services:
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
# Deploy directory path for bot-triggered deployments
- DEPLOY_DIR=/app/deploy
- DOCKER_HOST=tcp://socket-proxy:2375
depends_on:
db:
condition: service_healthy
socket-proxy:
condition: service_started
networks:
- internal
- web

View File

@@ -1,333 +0,0 @@
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
// Mock child_process BEFORE importing the service
const mockExec = mock((cmd: string, callback?: any) => {
// Handle calls without callback (like exec().unref())
if (!callback) {
return { unref: () => { } };
}
// Simulate successful command execution
let stdout = "";
if (cmd.includes("git rev-parse --abbrev-ref")) {
stdout = "main\n";
} else if (cmd.includes("git rev-parse --short")) {
stdout = "abc1234\n";
} else if (cmd.includes("git rev-parse HEAD")) {
stdout = "abc1234567890\n";
} else if (cmd.includes("git fetch")) {
stdout = "";
} else if (cmd.includes("git log")) {
stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2";
} else if (cmd.includes("git diff")) {
stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts";
} else if (cmd.includes("git reset")) {
stdout = "HEAD is now at abcdef Update 1";
} else if (cmd.includes("bun install")) {
stdout = "Installed dependencies";
} else if (cmd.includes("drizzle-kit migrate")) {
stdout = "Migrations applied";
} else if (cmd.includes("bun run build")) {
stdout = "Build completed";
}
callback(null, stdout, "");
});
mock.module("child_process", () => ({
exec: mockExec
}));
// Mock fs/promises
const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve());
const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}"));
const mockUnlink = mock((_path: string) => Promise.resolve());
mock.module("fs/promises", () => ({
writeFile: mockWriteFile,
readFile: mockReadFile,
unlink: mockUnlink
}));
// Mock view module to avoid import issues
mock.module("@/modules/admin/update.view", () => ({
getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }),
getPostRestartProgressEmbed: () => ({ title: "Progress..." }),
}));
describe("UpdateService", () => {
let UpdateService: any;
beforeEach(async () => {
mockExec.mockClear();
mockWriteFile.mockClear();
mockReadFile.mockClear();
mockUnlink.mockClear();
// Dynamically import to ensure mock is used
const module = await import("./update.service");
UpdateService = module.UpdateService;
});
afterAll(() => {
mock.restore();
});
describe("checkForUpdates", () => {
test("should return updates if git log has output", async () => {
const result = await UpdateService.checkForUpdates();
expect(result.hasUpdates).toBe(true);
expect(result.branch).toBe("main");
expect(result.commits.length).toBeGreaterThan(0);
expect(result.commits[0].message).toContain("Update 1");
});
test("should call git rev-parse, fetch, and log commands", async () => {
await UpdateService.checkForUpdates();
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
});
test("should include requirements in the response", async () => {
const result = await UpdateService.checkForUpdates();
expect(result.requirements).toBeDefined();
expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock
expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock
expect(result.requirements.changedFiles).toContain("package.json");
});
});
describe("performUpdate", () => {
test("should run git reset --hard with correct branch", async () => {
await UpdateService.performUpdate("main");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true);
});
});
describe("checkUpdateRequirements (deprecated)", () => {
test("should detect package.json and schema.ts changes", async () => {
const result = await UpdateService.checkUpdateRequirements("main");
expect(result.needsRootInstall).toBe(true);
expect(result.needsMigrations).toBe(true);
expect(result.error).toBeUndefined();
});
test("should call git diff with correct branch", async () => {
await UpdateService.checkUpdateRequirements("develop");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true);
});
});
describe("installDependencies", () => {
test("should run bun install for root only", async () => {
const output = await UpdateService.installDependencies({ root: true, web: false });
expect(output).toContain("Root");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
});
test("should run bun install for both root and web in parallel", async () => {
const output = await UpdateService.installDependencies({ root: true, web: true });
expect(output).toContain("Root");
expect(output).toContain("Web");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true);
});
});
describe("categorizeChanges", () => {
test("should categorize files correctly", () => {
const files = [
"bot/commands/admin/update.ts",
"bot/modules/admin/update.view.ts",
"web/src/components/Button.tsx",
"shared/lib/utils.ts",
"package.json",
"drizzle/0001_migration.sql"
];
const categories = UpdateService.categorizeChanges(files);
expect(categories["Commands"]).toBe(1);
expect(categories["Modules"]).toBe(1);
expect(categories["Web Dashboard"]).toBe(1);
expect(categories["Library"]).toBe(1);
expect(categories["Dependencies"]).toBe(1);
expect(categories["Database"]).toBe(1);
});
});
describe("prepareRestartContext", () => {
test("should write context to file", async () => {
const context = {
channelId: "123",
userId: "456",
timestamp: Date.now(),
runMigrations: true,
installDependencies: false,
buildWebAssets: false,
previousCommit: "abc1234",
newCommit: "def5678"
};
await UpdateService.prepareRestartContext(context);
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("restart_context");
expect(JSON.parse(lastCall![1])).toEqual(context);
});
});
describe("saveRollbackPoint", () => {
test("should save current commit hash to file", async () => {
const commit = await UpdateService.saveRollbackPoint();
expect(commit).toBeTruthy();
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall![0]).toContain("rollback_commit");
});
});
describe("hasRollbackPoint", () => {
test("should return true when rollback file exists", async () => {
mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123"));
// Clear cache first
(UpdateService as any).rollbackPointExists = null;
const result = await UpdateService.hasRollbackPoint();
expect(result).toBe(true);
});
test("should return false when rollback file does not exist", async () => {
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
// Clear cache first
(UpdateService as any).rollbackPointExists = null;
const result = await UpdateService.hasRollbackPoint();
expect(result).toBe(false);
});
});
describe("triggerRestart", () => {
test("should use RESTART_COMMAND env var when set", async () => {
const originalEnv = process.env.RESTART_COMMAND;
process.env.RESTART_COMMAND = "pm2 restart bot";
await UpdateService.triggerRestart();
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true);
process.env.RESTART_COMMAND = originalEnv;
});
test("should call process.exit when no env var is set", async () => {
const originalEnv = process.env.RESTART_COMMAND;
delete process.env.RESTART_COMMAND;
// Just verify it doesn't throw - actual process.exit is mocked by setTimeout
await UpdateService.triggerRestart();
process.env.RESTART_COMMAND = originalEnv;
});
});
describe("handlePostRestart", () => {
const createMockClient = (channel: any = null) => ({
channels: {
fetch: mock(() => Promise.resolve(channel))
}
});
const createMockChannel = () => ({
isSendable: () => true,
send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }))
});
test("should ignore stale context (>10 mins old)", async () => {
const staleContext = {
channelId: "123",
userId: "456",
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
runMigrations: true,
installDependencies: true,
buildWebAssets: false,
previousCommit: "abc",
newCommit: "def"
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
const mockChannel = createMockChannel();
// Create mock with instanceof support
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
const mockClient = createMockClient(channel);
await UpdateService.handlePostRestart(mockClient);
// Should not send any message for stale context
expect(mockChannel.send).not.toHaveBeenCalled();
// Should clean up the context file
expect(mockUnlink).toHaveBeenCalled();
});
test("should do nothing if no context file exists", async () => {
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
const mockClient = createMockClient();
await UpdateService.handlePostRestart(mockClient);
// Should not throw and not try to clean up
expect(mockUnlink).not.toHaveBeenCalled();
});
test("should clean up context file after processing", async () => {
const validContext = {
channelId: "123",
userId: "456",
timestamp: Date.now(),
runMigrations: false,
installDependencies: false,
buildWebAssets: false,
previousCommit: "abc",
newCommit: "def"
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
// Create a proper TextChannel mock
const { TextChannel } = await import("discord.js");
const mockChannel = Object.create(TextChannel.prototype);
mockChannel.isSendable = () => true;
mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }));
const mockClient = createMockClient(mockChannel);
await UpdateService.handlePostRestart(mockClient);
expect(mockUnlink).toHaveBeenCalled();
});
});
});

View File

@@ -1,601 +0,0 @@
import { exec, type ExecException } from "child_process";
import { promisify } from "util";
import { writeFile, readFile, unlink } from "fs/promises";
import * as path from "path";
import { Client, TextChannel } from "discord.js";
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
import type { PostRestartResult } from "@/modules/admin/update.view";
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
const execAsync = promisify(exec);
// Constants
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands
const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation
const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build
/**
* Execute a command with timeout protection
*/
async function execWithTimeout(
cmd: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
options: { cwd?: string } = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = exec(cmd, {
cwd: options.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_SSH_COMMAND: "ssh -o BatchMode=yes"
}
}, (error: ExecException | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
}, timeoutMs);
child.on("exit", () => clearTimeout(timer));
});
}
export class UpdateService {
private static readonly CONTEXT_FILE = ".restart_context.json";
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
// Cache for rollback state (set when we save, cleared on cleanup)
private static rollbackPointExists: boolean | null = null;
/**
* Check for available updates with detailed commit information
* Optimized: Parallel git commands and combined requirements check
*/
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
const cwd = this.getDeployDir();
// Get branch first (needed for subsequent commands)
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD", DEFAULT_TIMEOUT_MS, { cwd });
const branch = branchName.trim();
// Parallel execution: get current commit while fetching
const [currentResult] = await Promise.all([
execWithTimeout("git rev-parse --short HEAD", DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git fetch origin ${branch} --prune`, DEFAULT_TIMEOUT_MS, { cwd }) // Only fetch current branch
]);
const currentCommit = currentResult.stdout.trim();
// After fetch completes, get remote info in parallel
const [latestResult, logResult, diffResult] = await Promise.all([
execWithTimeout(`git rev-parse --short origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`, DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd })
]);
const latestCommit = latestResult.stdout.trim();
// Parse commit log
const commits: CommitInfo[] = logResult.stdout
.trim()
.split("\n")
.filter(line => line.length > 0)
.map(line => {
const [hash, message, author] = line.split("|");
return { hash: hash || "", message: message || "", author: author || "" };
});
// Parse changed files and analyze requirements in one pass
const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0);
const requirements = this.analyzeChangedFiles(changedFiles);
return {
hasUpdates: commits.length > 0,
branch,
currentCommit,
latestCommit,
commitCount: commits.length,
commits,
requirements
};
}
/**
* Analyze changed files to determine update requirements
* Extracted for reuse and clarity
*/
private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult {
const needsRootInstall = changedFiles.some(file =>
file === "package.json" || file === "bun.lock"
);
const needsWebInstall = changedFiles.some(file =>
file === "web/package.json" || file === "web/bun.lock"
);
// Only rebuild web if essential source files changed
const needsWebBuild = changedFiles.some(file =>
file.match(/^web\/src\/(components|pages|lib|index)/) ||
file === "web/build.ts" ||
file === "web/tailwind.config.ts" ||
file === "web/tsconfig.json"
);
const needsMigrations = changedFiles.some(file =>
file.includes("schema.ts") || file.startsWith("drizzle/")
);
return {
needsRootInstall,
needsWebInstall,
needsWebBuild,
needsMigrations,
changedFiles
};
}
/**
* @deprecated Use checkForUpdates() which now includes requirements
* Kept for backwards compatibility
*/
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
const cwd = this.getDeployDir();
try {
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd });
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
return this.analyzeChangedFiles(changedFiles);
} catch (e) {
console.error("Failed to check update requirements:", e);
return {
needsRootInstall: false,
needsWebInstall: false,
needsWebBuild: false,
needsMigrations: false,
changedFiles: [],
error: e instanceof Error ? e : new Error(String(e))
};
}
}
/**
* Get a summary of changed file categories
*/
static categorizeChanges(changedFiles: string[]): Record<string, number> {
const categories: Record<string, number> = {};
for (const file of changedFiles) {
let category = "Other";
if (file.startsWith("bot/commands/")) category = "Commands";
else if (file.startsWith("bot/modules/")) category = "Modules";
else if (file.startsWith("web/")) category = "Web Dashboard";
else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library";
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
else if (file.endsWith(".test.ts")) category = "Tests";
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
categories[category] = (categories[category] || 0) + 1;
}
return categories;
}
/**
* Save the current commit for potential rollback
*/
static async saveRollbackPoint(): Promise<string> {
const cwd = this.getDeployDir();
const { stdout } = await execWithTimeout("git rev-parse HEAD", DEFAULT_TIMEOUT_MS, { cwd });
const commit = stdout.trim();
await writeFile(this.ROLLBACK_FILE, commit);
this.rollbackPointExists = true; // Cache the state
return commit;
}
/**
* Rollback to the previous commit
*/
static async rollback(): Promise<{ success: boolean; message: string }> {
const cwd = this.getDeployDir();
try {
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`, DEFAULT_TIMEOUT_MS, { cwd });
await unlink(this.ROLLBACK_FILE);
this.rollbackPointExists = false;
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "No rollback point available"
};
}
}
/**
* Check if a rollback point exists
* Uses cache when available to avoid file system access
*/
static async hasRollbackPoint(): Promise<boolean> {
if (this.rollbackPointExists !== null) {
return this.rollbackPointExists;
}
try {
await readFile(this.ROLLBACK_FILE, "utf-8");
this.rollbackPointExists = true;
return true;
} catch {
this.rollbackPointExists = false;
return false;
}
}
/**
* Perform the git update
*/
static async performUpdate(branch: string): Promise<void> {
const cwd = this.getDeployDir();
await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd });
}
/**
* Install dependencies for specified projects
* Optimized: Parallel installation
*/
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
const tasks: Promise<{ label: string; output: string }>[] = [];
// Install dependencies in the App directory (not deploy dir) because we are updating the RUNNING app
// NOTE: If we hot-reload, we want to install in the current directory.
// If we restart, dependencies should be in the image.
// Assuming this method is used for hot-patching/minor updates without container rebuild.
if (options.root) {
tasks.push(
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" }))
);
}
if (options.web) {
tasks.push(
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" }))
);
}
const results = await Promise.all(tasks);
return results.map(r => `${r.label}: ${r.output}`).join("\n");
}
/**
* Prepare restart context with rollback info
*/
static async prepareRestartContext(context: RestartContext): Promise<void> {
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await writeFile(filePath, JSON.stringify(context));
}
/**
* Trigger a restart
*
* In production Docker (with restart: unless-stopped), exiting the process
* will cause Docker to restart the container. For a full rebuild with code changes,
* use the deploy.sh script or GitHub Actions CI/CD instead of this command.
*
* Note: The /update command works for hot-reloading in development and minor
* restarts in production, but for production deployments with new code,
* use: `cd ~/Aurora && git pull && docker compose -f docker-compose.prod.yml up -d --build`
*/
static async triggerRestart(): Promise<void> {
if (process.env.RESTART_COMMAND) {
// Custom restart command from environment
exec(process.env.RESTART_COMMAND).unref();
} else {
// Exit process - Docker will restart container, dev mode will hot-reload
setTimeout(() => process.exit(0), 100);
}
}
/**
* Handle post-restart tasks
*/
static async handlePostRestart(client: Client): Promise<void> {
try {
const context = await this.loadRestartContext();
if (!context) return;
if (this.isContextStale(context)) {
await this.cleanupContext();
return;
}
const channel = await this.fetchNotificationChannel(client, context.channelId);
if (!channel) {
await this.cleanupContext();
return;
}
const result = await this.executePostRestartTasks(context, channel);
await this.notifyPostRestartResult(channel, result);
await this.cleanupContext();
} catch (e) {
console.error("Failed to handle post-restart context:", e);
}
}
// --- Private Helper Methods ---
private static async loadRestartContext(): Promise<RestartContext | null> {
try {
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
const contextData = await readFile(filePath, "utf-8");
return JSON.parse(contextData) as RestartContext;
} catch {
return null;
}
}
private static isContextStale(context: RestartContext): boolean {
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
}
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
try {
const channel = await client.channels.fetch(channelId);
if (channel && channel.isSendable() && channel instanceof TextChannel) {
return channel;
}
return null;
} catch {
return null;
}
}
private static async executePostRestartTasks(
context: RestartContext,
channel: TextChannel
): Promise<PostRestartResult> {
const result: PostRestartResult = {
installSuccess: true,
installOutput: "",
webBuildSuccess: true,
webBuildOutput: "",
migrationSuccess: true,
migrationOutput: "",
ranInstall: context.installDependencies,
ranWebBuild: context.buildWebAssets,
ranMigrations: context.runMigrations,
previousCommit: context.previousCommit,
newCommit: context.newCommit
};
// Track progress for consolidated message
const progress: PostRestartProgress = {
installDeps: context.installDependencies,
buildWeb: context.buildWebAssets,
runMigrations: context.runMigrations,
currentStep: "starting"
};
// Only send progress message if there are tasks to run
const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations;
let progressMessage = hasTasks
? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] })
: null;
// Helper to update progress message
const updateProgress = async () => {
if (progressMessage) {
await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] });
}
};
// 1. Install Dependencies if needed (PARALLELIZED)
if (context.installDependencies) {
try {
progress.currentStep = "install";
await updateProgress();
// Parallel installation of root and web dependencies
const [rootResult, webResult] = await Promise.all([
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })),
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) }))
]);
result.installSuccess = rootResult.success && webResult.success;
result.installOutput = `📦 Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`;
progress.installDone = true;
if (!result.installSuccess) {
console.error("Dependency Install Failed:", result.installOutput);
}
} catch (err: unknown) {
result.installSuccess = false;
result.installOutput = err instanceof Error ? err.message : String(err);
progress.installDone = true;
console.error("Dependency Install Failed:", err);
}
}
// 2. Build Web Assets if needed
if (context.buildWebAssets) {
try {
progress.currentStep = "build";
await updateProgress();
const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS);
result.webBuildOutput = stdout.trim() || "Build completed successfully";
progress.buildDone = true;
} catch (err: unknown) {
result.webBuildSuccess = false;
result.webBuildOutput = err instanceof Error ? err.message : String(err);
progress.buildDone = true;
console.error("Web Build Failed:", err);
}
}
// 3. Run Migrations
if (context.runMigrations) {
try {
progress.currentStep = "migrate";
await updateProgress();
const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS);
result.migrationOutput = stdout;
progress.migrateDone = true;
} catch (err: unknown) {
result.migrationSuccess = false;
result.migrationOutput = err instanceof Error ? err.message : String(err);
progress.migrateDone = true;
console.error("Migration Failed:", err);
}
}
// Delete progress message before final result
if (progressMessage) {
try {
await progressMessage.delete();
} catch {
// Message may already be deleted, ignore
}
}
return result;
}
private static async notifyPostRestartResult(
channel: TextChannel,
result: PostRestartResult
): Promise<void> {
// Use cached rollback state - we just saved it before restart
const hasRollback = await this.hasRollbackPoint();
await channel.send(getPostRestartEmbed(result, hasRollback));
}
private static async cleanupContext(): Promise<void> {
try {
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await unlink(filePath);
} catch {
// File may not exist, ignore
}
// Don't clear rollback cache here - rollback file persists
}
// =========================================================================
// Bot-Triggered Deployment Methods
// =========================================================================
private static readonly DEPLOY_TIMEOUT_MS = 300_000; // 5 minutes for full deploy
/**
* Get the deploy directory path from environment or default
*/
static getDeployDir(): string {
return process.env.DEPLOY_DIR || "/app/deploy";
}
/**
* Check if deployment is available (docker socket accessible)
*/
static async isDeployAvailable(): Promise<boolean> {
try {
await execWithTimeout("docker --version", 5000);
return true;
} catch {
return false;
}
}
/**
* Perform a full deployment: git pull + docker compose rebuild
* This will restart the container with the new code
*
* @param onProgress - Callback for progress updates
* @returns Object with success status, output, and commit info
*/
static async performDeploy(onProgress?: (step: string) => void): Promise<{
success: boolean;
previousCommit: string;
newCommit: string;
output: string;
error?: string;
}> {
const deployDir = this.getDeployDir();
let previousCommit = "";
let newCommit = "";
let output = "";
try {
// 1. Get current commit
onProgress?.("Getting current version...");
const { stdout: currentSha } = await execWithTimeout(
`cd ${deployDir} && git rev-parse --short HEAD`,
DEFAULT_TIMEOUT_MS
);
previousCommit = currentSha.trim();
output += `📍 Current: ${previousCommit}\n`;
// 2. Pull latest changes
onProgress?.("Pulling latest code...");
const { stdout: pullOutput } = await execWithTimeout(
`cd ${deployDir} && git pull origin main`,
DEFAULT_TIMEOUT_MS
);
output += `📥 Pull: ${pullOutput.includes("Already up to date") ? "Already up to date" : "Updated"}\n`;
// 3. Get new commit
const { stdout: newSha } = await execWithTimeout(
`cd ${deployDir} && git rev-parse --short HEAD`,
DEFAULT_TIMEOUT_MS
);
newCommit = newSha.trim();
output += `📍 New: ${newCommit}\n`;
// 4. Rebuild and restart container (this will kill the current process)
onProgress?.("Rebuilding and restarting...");
// Use spawn with detached mode so the command continues after we exit
const { spawn } = await import("child_process");
const deployProcess = spawn(
"sh",
["-c", `cd ${deployDir} && docker compose -f docker-compose.prod.yml up -d --build`],
{
detached: true,
stdio: "ignore"
}
);
deployProcess.unref();
output += `🚀 Deploy triggered - container will restart\n`;
console.log("Deploy triggered successfully:", output);
return {
success: true,
previousCommit,
newCommit,
output
};
} catch (error) {
return {
success: false,
previousCommit,
newCommit,
output,
error: error instanceof Error ? error.message : String(error)
};
}
}
}

View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -e
echo "🔍 Finding test files..."
TEST_FILES=$(find . -name "*.test.ts" -not -path "*/node_modules/*")
if [ -z "$TEST_FILES" ]; then
echo "⚠️ No test files found!"
exit 0
fi
echo "🧪 Running tests sequentially..."
FAILED=0
for FILE in $TEST_FILES; do
echo "---------------------------------------------------"
echo "running: $FILE"
if bun test "$FILE"; then
echo "✅ passed: $FILE"
else
echo "❌ failed: $FILE"
FAILED=1
# Fail fast
exit 1
fi
done
if [ $FAILED -eq 0 ]; then
echo "---------------------------------------------------"
echo "✅ All tests passed!"
exit 0
else
echo "---------------------------------------------------"
echo "❌ Some tests failed."
exit 1
fi