feat(KB-327): add automatic database backup system
- Add backup settings to ProjectSettings with schedule, retention, and directory config - Create BackupManager with create, list, cleanup, and restore operations - Add CLI backup commands: --create, --list, --restore, --cleanup - Implement backup validation and automation sync for scheduled backups - Add dashboard backup settings UI with stats and 'Backup Now' button - Add backup API routes for settings management and manual operations - Include comprehensive backup tests and changeset for patch release - Document backup configuration and recovery in AGENTS.md
This commit is contained in:
@@ -42,6 +42,7 @@ const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -83,6 +84,10 @@ Usage:
|
||||
fn git push Push current branch
|
||||
fn git pull Pull current branch
|
||||
fn git fetch [remote] Fetch from remote (default: origin)
|
||||
fn backup --create Create a database backup immediately
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> Restore database from a backup file
|
||||
fn backup --cleanup Remove old backups exceeding retention limit
|
||||
|
||||
Options:
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
@@ -463,6 +468,28 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "backup": {
|
||||
const create = args.includes("--create");
|
||||
const list = args.includes("--list");
|
||||
const cleanup = args.includes("--cleanup");
|
||||
const restoreIdx = args.indexOf("--restore");
|
||||
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
|
||||
|
||||
if (create) {
|
||||
await runBackupCreate();
|
||||
} else if (list) {
|
||||
await runBackupList();
|
||||
} else if (cleanup) {
|
||||
await runBackupCleanup();
|
||||
} else if (restoreFile) {
|
||||
await runBackupRestore(restoreFile);
|
||||
} else {
|
||||
console.error("Usage: fn backup --create | --list | --cleanup | --restore <filename>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.log(HELP);
|
||||
|
||||
126
packages/cli/src/commands/backup.ts
Normal file
126
packages/cli/src/commands/backup.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
runBackupCommand,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
|
||||
/**
|
||||
* Find the project root and create a backup manager.
|
||||
*/
|
||||
async function getBackupManager(): Promise<{
|
||||
manager: BackupManager;
|
||||
store: TaskStore;
|
||||
kbDir: string;
|
||||
}> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
// Access the private kbDir property via type assertion
|
||||
const kbDir = (store as unknown as { kbDir: string }).kbDir;
|
||||
const settings = await store.getSettings();
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
return { manager, store, kbDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database backup immediately.
|
||||
* Usage: kb backup --create
|
||||
*/
|
||||
export async function runBackupCreate(): Promise<void> {
|
||||
const { manager, kbDir, store } = await getBackupManager();
|
||||
const settings = await store.getSettings();
|
||||
|
||||
console.log("Creating database backup...");
|
||||
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.output);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all database backups.
|
||||
* Usage: kb backup --list
|
||||
*/
|
||||
export async function runBackupList(): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
|
||||
const backups = await manager.listBackups();
|
||||
|
||||
if (backups.length === 0) {
|
||||
console.log("No backups found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${backups.length} backup(s):\n`);
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
|
||||
const formattedTotal = formatBytes(totalSize);
|
||||
|
||||
console.log("Date Size Filename");
|
||||
console.log("-".repeat(60));
|
||||
|
||||
for (const backup of backups) {
|
||||
const date = new Date(backup.createdAt).toLocaleString();
|
||||
const size = formatBytes(backup.size).padEnd(8);
|
||||
console.log(`${date} ${size} ${backup.filename}`);
|
||||
}
|
||||
|
||||
console.log("-".repeat(60));
|
||||
console.log(`Total: ${formattedTotal}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from a backup file.
|
||||
* Usage: kb backup --restore <filename>
|
||||
*/
|
||||
export async function runBackupRestore(filename: string): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
|
||||
console.log(`Restoring backup: ${filename}`);
|
||||
console.log("A pre-restore backup will be created first.\n");
|
||||
|
||||
try {
|
||||
await manager.restoreBackup(filename, { createPreRestoreBackup: true });
|
||||
console.log(`Successfully restored from ${filename}`);
|
||||
console.log("Note: The pre-restore backup was saved in case you need to undo this operation.");
|
||||
} catch (err) {
|
||||
console.error(`Restore failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old backups exceeding retention limit.
|
||||
* Usage: kb backup --cleanup
|
||||
*/
|
||||
export async function runBackupCleanup(): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
|
||||
console.log("Cleaning up old backups...");
|
||||
|
||||
const deletedCount = await manager.cleanupOldBackups();
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log(`Removed ${deletedCount} old backup(s).`);
|
||||
} else {
|
||||
console.log("No backups to clean up (within retention limit).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
433
packages/core/src/backup.test.ts
Normal file
433
packages/core/src/backup.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { rm, mkdir, writeFile, readdir } from "node:fs/promises";
|
||||
import {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
generateBackupFilename,
|
||||
validateBackupSchedule,
|
||||
validateBackupRetention,
|
||||
validateBackupDir,
|
||||
runBackupCommand,
|
||||
} from "./backup.js";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
|
||||
// Helper to wait with a delay that ensures different timestamps
|
||||
async function waitForNextSecond(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
}
|
||||
|
||||
describe("BackupManager", () => {
|
||||
let tempDir: string;
|
||||
let kbDir: string;
|
||||
let backupManager: BackupManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
kbDir = join(tempDir, ".kb");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
// Create a dummy database file
|
||||
writeFileSync(join(kbDir, "kb.db"), "dummy database content");
|
||||
backupManager = new BackupManager(kbDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createBackup", () => {
|
||||
it("should create a backup file with correct name pattern", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
expect(backup.filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
|
||||
expect(existsSync(backup.path)).toBe(true);
|
||||
});
|
||||
|
||||
it("should copy database content correctly", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
const originalContent = readFileSync(join(kbDir, "kb.db"), "utf-8");
|
||||
const backupContent = readFileSync(backup.path, "utf-8");
|
||||
|
||||
expect(backupContent).toBe(originalContent);
|
||||
});
|
||||
|
||||
it("should return correct backup info", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
expect(backup.filename).toBeDefined();
|
||||
expect(backup.createdAt).toBeDefined();
|
||||
expect(backup.size).toBeGreaterThan(0);
|
||||
expect(backup.path).toContain(backup.filename);
|
||||
});
|
||||
|
||||
it("should create backup directory if it does not exist", async () => {
|
||||
const customBackupDir = "custom-backups";
|
||||
const manager = new BackupManager(kbDir, { backupDir: customBackupDir });
|
||||
|
||||
const customBackupPath = join(tempDir, customBackupDir);
|
||||
expect(existsSync(customBackupPath)).toBe(false);
|
||||
|
||||
await manager.createBackup();
|
||||
|
||||
expect(existsSync(customBackupPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBackups", () => {
|
||||
it("should return empty array when no backups exist", async () => {
|
||||
const backups = await backupManager.listBackups();
|
||||
expect(backups).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return sorted array newest-first", async () => {
|
||||
// Create multiple backups with delays to ensure different timestamps
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
await backupManager.createBackup();
|
||||
|
||||
const backups = await backupManager.listBackups();
|
||||
|
||||
expect(backups).toHaveLength(3);
|
||||
// Verify sorted by createdAt descending
|
||||
for (let i = 0; i < backups.length - 1; i++) {
|
||||
expect(backups[i].createdAt >= backups[i + 1].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should only list files matching backup pattern", async () => {
|
||||
await backupManager.createBackup();
|
||||
|
||||
// Create some non-backup files
|
||||
const backupDir = join(tempDir, ".kb/backups");
|
||||
await writeFile(join(backupDir, "not-a-backup.txt"), "content");
|
||||
await writeFile(join(backupDir, "random.db"), "content");
|
||||
|
||||
const backups = await backupManager.listBackups();
|
||||
|
||||
expect(backups).toHaveLength(1);
|
||||
expect(backups[0].filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
|
||||
});
|
||||
|
||||
it("should return correct file sizes", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
const backups = await backupManager.listBackups();
|
||||
|
||||
expect(backups[0].size).toBe(backup.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOldBackups", () => {
|
||||
it("should not delete when backup count is within retention", async () => {
|
||||
// Create 3 backups with retention of 7
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await backupManager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
const deleted = await backupManager.cleanupOldBackups();
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
const backups = await backupManager.listBackups();
|
||||
expect(backups).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should delete oldest backups exceeding retention", async () => {
|
||||
const manager = new BackupManager(kbDir, { retention: 2 });
|
||||
|
||||
// Create 4 backups with 1-second delays to ensure different timestamps
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
const deleted = await manager.cleanupOldBackups();
|
||||
|
||||
expect(deleted).toBe(2); // 4 - 2 = 2 deleted
|
||||
const backups = await manager.listBackups();
|
||||
expect(backups).toHaveLength(2);
|
||||
}, 10000);
|
||||
|
||||
it("should keep the newest backups after cleanup", async () => {
|
||||
const manager = new BackupManager(kbDir, { retention: 2 });
|
||||
|
||||
// Create 4 backups and record their names
|
||||
const backupNames: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const backup = await manager.createBackup();
|
||||
backupNames.push(backup.filename);
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
await manager.cleanupOldBackups();
|
||||
|
||||
const backups = await manager.listBackups();
|
||||
const remainingNames = backups.map((b) => b.filename);
|
||||
|
||||
// Should keep the 2 newest (last 2 in the array)
|
||||
expect(remainingNames).toContain(backupNames[2]);
|
||||
expect(remainingNames).toContain(backupNames[3]);
|
||||
expect(remainingNames).not.toContain(backupNames[0]);
|
||||
expect(remainingNames).not.toContain(backupNames[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreBackup", () => {
|
||||
it("should restore backup to main database location", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
// Modify the original database
|
||||
await writeFile(join(kbDir, "kb.db"), "modified content");
|
||||
|
||||
// Restore the backup
|
||||
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
|
||||
|
||||
// Verify the restore
|
||||
const restoredContent = readFileSync(join(kbDir, "kb.db"), "utf-8");
|
||||
expect(restoredContent).toBe("dummy database content");
|
||||
});
|
||||
|
||||
it("should throw when backup file does not exist", async () => {
|
||||
await expect(
|
||||
backupManager.restoreBackup("nonexistent-backup.db", { createPreRestoreBackup: false })
|
||||
).rejects.toThrow("Backup file not found");
|
||||
});
|
||||
|
||||
it("should create pre-restore backup by default", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
|
||||
// Wait to ensure different timestamp
|
||||
await waitForNextSecond();
|
||||
|
||||
// Restore with default options (should create pre-restore backup)
|
||||
await backupManager.restoreBackup(backup.filename);
|
||||
|
||||
// Check for pre-restore backup
|
||||
const backups = await backupManager.listBackups();
|
||||
const preRestoreBackup = backups.find((b) => b.filename.includes("pre-restore"));
|
||||
|
||||
expect(preRestoreBackup).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateBackupFilename", () => {
|
||||
it("should generate filename with correct pattern", () => {
|
||||
const filename = generateBackupFilename();
|
||||
expect(filename).toMatch(/^kb-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
|
||||
});
|
||||
|
||||
it("should generate unique filenames for different timestamps", async () => {
|
||||
const filename1 = generateBackupFilename();
|
||||
await waitForNextSecond();
|
||||
const filename2 = generateBackupFilename();
|
||||
|
||||
expect(filename1).not.toBe(filename2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBackupSchedule", () => {
|
||||
it("should return true for valid cron expressions", () => {
|
||||
expect(validateBackupSchedule("0 2 * * *")).toBe(true); // Daily at 2 AM
|
||||
expect(validateBackupSchedule("0 * * * *")).toBe(true); // Hourly
|
||||
expect(validateBackupSchedule("*/15 * * * *")).toBe(true); // Every 15 minutes
|
||||
expect(validateBackupSchedule("0 0 * * 0")).toBe(true); // Weekly on Sunday
|
||||
});
|
||||
|
||||
it("should return false for invalid cron expressions", () => {
|
||||
expect(validateBackupSchedule("invalid")).toBe(false);
|
||||
expect(validateBackupSchedule("")).toBe(false);
|
||||
expect(validateBackupSchedule(" ")).toBe(false);
|
||||
expect(validateBackupSchedule("* *")).toBe(false); // Too few fields
|
||||
expect(validateBackupSchedule("99 99 99 99 99")).toBe(false); // Out of range
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBackupRetention", () => {
|
||||
it("should return true for valid retention values", () => {
|
||||
expect(validateBackupRetention(1)).toBe(true);
|
||||
expect(validateBackupRetention(7)).toBe(true);
|
||||
expect(validateBackupRetention(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid retention values", () => {
|
||||
expect(validateBackupRetention(0)).toBe(false);
|
||||
expect(validateBackupRetention(-1)).toBe(false);
|
||||
expect(validateBackupRetention(101)).toBe(false);
|
||||
expect(validateBackupRetention(1.5)).toBe(false); // Not an integer
|
||||
expect(validateBackupRetention(NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBackupDir", () => {
|
||||
it("should return true for valid relative paths", () => {
|
||||
expect(validateBackupDir(".kb/backups")).toBe(true);
|
||||
expect(validateBackupDir("backups")).toBe(true);
|
||||
expect(validateBackupDir("data/backups/kb")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for absolute paths", () => {
|
||||
expect(validateBackupDir("/absolute/path")).toBe(false);
|
||||
expect(validateBackupDir("/home/user/backups")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for paths with parent traversal", () => {
|
||||
expect(validateBackupDir("../backups")).toBe(false);
|
||||
expect(validateBackupDir(".kb/../backups")).toBe(false);
|
||||
expect(validateBackupDir("data/../../backups")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for Windows absolute paths", () => {
|
||||
expect(validateBackupDir("C:\\backups")).toBe(false);
|
||||
expect(validateBackupDir("D:\\data\\backups")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBackupManager", () => {
|
||||
it("should create manager with default options when no settings provided", () => {
|
||||
const manager = createBackupManager("/tmp/.kb");
|
||||
expect(manager).toBeInstanceOf(BackupManager);
|
||||
});
|
||||
|
||||
it("should use settings when provided", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
const kbDir = join(tempDir, ".kb");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
writeFileSync(join(kbDir, "kb.db"), "test");
|
||||
|
||||
const settings: Partial<ProjectSettings> = {
|
||||
autoBackupDir: "custom/backups",
|
||||
autoBackupRetention: 2,
|
||||
};
|
||||
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
|
||||
// Create 4 backups with 1-second delays
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
// Cleanup should leave only 2
|
||||
const deleted = await manager.cleanupOldBackups();
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("runBackupCommand", () => {
|
||||
let tempDir: string;
|
||||
let kbDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
kbDir = join(tempDir, ".kb");
|
||||
await mkdir(kbDir, { recursive: true });
|
||||
writeFileSync(join(kbDir, "kb.db"), "dummy database content");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should create backup regardless of autoBackupEnabled setting", async () => {
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: false, // Disabled, but should still work when called manually
|
||||
};
|
||||
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
// Should succeed even when autoBackupEnabled is false
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create backup when enabled", async () => {
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: true,
|
||||
autoBackupRetention: 7,
|
||||
};
|
||||
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(result.output).toContain("Backup created");
|
||||
});
|
||||
|
||||
it("should return failure for invalid schedule", async () => {
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: true,
|
||||
autoBackupSchedule: "invalid-cron",
|
||||
};
|
||||
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.output).toContain("Invalid backup schedule");
|
||||
});
|
||||
|
||||
it("should cleanup old backups after creation", async () => {
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: true,
|
||||
autoBackupRetention: 2,
|
||||
};
|
||||
|
||||
// Create 3 backups first (manually to test cleanup) with delays
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await manager.createBackup();
|
||||
await waitForNextSecond();
|
||||
}
|
||||
|
||||
// Now run backup command
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return failure when database file is missing", async () => {
|
||||
// Remove the database
|
||||
await rm(join(kbDir, "kb.db"));
|
||||
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: true,
|
||||
};
|
||||
|
||||
const result = await runBackupCommand(kbDir, settings);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.output).toContain("failed");
|
||||
});
|
||||
});
|
||||
423
packages/core/src/backup.ts
Normal file
423
packages/core/src/backup.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { cp, mkdir, readdir, stat, unlink } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
|
||||
/**
|
||||
* Metadata for a database backup file.
|
||||
*/
|
||||
export interface BackupInfo {
|
||||
/** Filename of the backup (e.g., "kb-2026-03-31-020000.db") */
|
||||
filename: string;
|
||||
/** ISO-8601 timestamp when the backup was created */
|
||||
createdAt: string;
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
/** Full absolute path to the backup file */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for configuring the backup manager.
|
||||
*/
|
||||
export interface BackupOptions {
|
||||
/** Directory for backup files, relative to the project root. Default: ".kb/backups" */
|
||||
backupDir?: string;
|
||||
/** Number of backups to retain. Default: 7 */
|
||||
retention?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages database backup operations including creation, listing,
|
||||
* cleanup of old backups, and restoration.
|
||||
*/
|
||||
export class BackupManager {
|
||||
private kbDir: string;
|
||||
private backupDir: string;
|
||||
private retention: number;
|
||||
|
||||
/**
|
||||
* Creates a new BackupManager instance.
|
||||
* @param kbDir - Absolute path to the .kb directory
|
||||
* @param options - Backup configuration options
|
||||
*/
|
||||
constructor(kbDir: string, options?: BackupOptions) {
|
||||
this.kbDir = kbDir;
|
||||
this.backupDir = options?.backupDir ?? ".kb/backups";
|
||||
this.retention = options?.retention ?? 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute path to the backup directory.
|
||||
*/
|
||||
private getBackupDirPath(): string {
|
||||
// The backupDir is relative to project root, which is parent of kbDir
|
||||
return join(this.kbDir, "..", this.backupDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timestamped backup of the database.
|
||||
* @returns BackupInfo for the newly created backup
|
||||
*/
|
||||
async createBackup(): Promise<BackupInfo> {
|
||||
const sourcePath = join(this.kbDir, "kb.db");
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
|
||||
// Ensure backup directory exists
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
|
||||
// Generate unique filename (handle collisions with counter suffix)
|
||||
let filename = generateBackupFilename();
|
||||
let targetPath = join(backupDirPath, filename);
|
||||
let counter = 1;
|
||||
|
||||
while (existsSync(targetPath)) {
|
||||
const baseName = filename.replace(/\.db$/, "");
|
||||
filename = `${baseName}-${counter}.db`;
|
||||
targetPath = join(backupDirPath, filename);
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Copy the database file
|
||||
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||
|
||||
// Get file stats
|
||||
const stats = await stat(targetPath);
|
||||
|
||||
return {
|
||||
filename,
|
||||
createdAt: new Date().toISOString(),
|
||||
size: stats.size,
|
||||
path: targetPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all backup files sorted by creation time (newest first).
|
||||
* @returns Array of BackupInfo objects
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
|
||||
try {
|
||||
const files = await readdir(backupDirPath);
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const filename of files) {
|
||||
// Match both regular backups (kb-YYYY-MM-DD-HHmmss.db or kb-YYYY-MM-DD-HHmmss-N.db) and pre-restore backups
|
||||
if (!filename.match(/^kb(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(backupDirPath, filename);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Parse timestamp from filename: kb-YYYY-MM-DD-HHmmss.db or kb-pre-restore-YYYY-MM-DD-HHmmss.db
|
||||
// Also handles counter suffix: kb-YYYY-MM-DD-HHmmss-N.db
|
||||
const match = filename.match(/^(kb(?:-pre-restore)?)-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
|
||||
const createdAt = match
|
||||
? `${match[2]}-${match[3]}-${match[4]}T${match[5]}:${match[6]}:${match[7]}Z`
|
||||
: stats.mtime.toISOString();
|
||||
|
||||
backups.push({
|
||||
filename,
|
||||
createdAt,
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first), then by filename for deterministic ordering
|
||||
return backups.sort((a, b) => {
|
||||
const timeCompare = b.createdAt.localeCompare(a.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
return b.filename.localeCompare(a.filename);
|
||||
});
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read - return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes old backups to maintain the retention limit.
|
||||
* Only removes regular backups, not pre-restore backups.
|
||||
* @returns Number of backups deleted
|
||||
*/
|
||||
async cleanupOldBackups(): Promise<number> {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
// Filter to only regular backups (not pre-restore)
|
||||
const regularBackups = backups.filter(b => !b.filename.includes("pre-restore"));
|
||||
|
||||
if (regularBackups.length <= this.retention) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sort ascending (oldest first) for deletion, using filename as secondary sort for determinism
|
||||
const sorted = [...regularBackups].sort((a, b) => {
|
||||
const timeCompare = a.createdAt.localeCompare(b.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
const toDelete = sorted.slice(0, sorted.length - this.retention);
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
await unlink(backup.path);
|
||||
deletedCount++;
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup to become the main database.
|
||||
* Optionally creates a pre-restore backup of the current database.
|
||||
* @param filename - Name of the backup file to restore
|
||||
* @param options - Restore options
|
||||
*/
|
||||
async restoreBackup(
|
||||
filename: string,
|
||||
options?: { createPreRestoreBackup?: boolean }
|
||||
): Promise<void> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
const sourcePath = join(backupDirPath, filename);
|
||||
const targetPath = join(this.kbDir, "kb.db");
|
||||
|
||||
// Verify source exists
|
||||
try {
|
||||
await stat(sourcePath);
|
||||
} catch {
|
||||
throw new Error(`Backup file not found: ${filename}`);
|
||||
}
|
||||
|
||||
// Optionally create pre-restore backup
|
||||
if (options?.createPreRestoreBackup ?? true) {
|
||||
const preRestoreFilename = `kb-pre-restore-${formatTimestamp(new Date())}.db`;
|
||||
const preRestorePath = join(backupDirPath, preRestoreFilename);
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
await cp(targetPath, preRestorePath, { preserveTimestamps: true });
|
||||
}
|
||||
|
||||
// Restore the backup
|
||||
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a backup filename with timestamp.
|
||||
* Format: kb-YYYY-MM-DD-HHmmss.db
|
||||
*/
|
||||
export function generateBackupFilename(): string {
|
||||
return `kb-${formatTimestamp(new Date())}.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as YYYY-MM-DD-HHmmss in UTC.
|
||||
*/
|
||||
function formatTimestamp(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hours = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a cron expression for backup scheduling.
|
||||
* @param schedule - Cron expression to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function validateBackupSchedule(schedule: string): boolean {
|
||||
if (!schedule || schedule.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
CronExpressionParser.parse(schedule);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the backup retention count.
|
||||
* @param retention - Number of backups to retain
|
||||
* @returns True if valid (1-100), false otherwise
|
||||
*/
|
||||
export function validateBackupRetention(retention: number): boolean {
|
||||
return Number.isInteger(retention) && retention >= 1 && retention <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the backup directory path.
|
||||
* Must be relative and not contain parent directory traversal.
|
||||
* @param dir - Directory path to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function validateBackupDir(dir: string): boolean {
|
||||
// Must be relative (not start with / or \)
|
||||
if (dir.startsWith("/") || dir.startsWith("\\")) {
|
||||
return false;
|
||||
}
|
||||
// Must not contain parent directory traversal
|
||||
if (dir.includes("..")) {
|
||||
return false;
|
||||
}
|
||||
// Must not be absolute path with drive letter (Windows)
|
||||
if (/^[a-zA-Z]:/.test(dir)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BackupManager with project settings.
|
||||
* @param kbDir - Absolute path to the .kb directory
|
||||
* @param settings - Project settings containing backup configuration
|
||||
* @returns Configured BackupManager instance
|
||||
*/
|
||||
export function createBackupManager(
|
||||
kbDir: string,
|
||||
settings?: Partial<ProjectSettings>
|
||||
): BackupManager {
|
||||
return new BackupManager(kbDir, {
|
||||
backupDir: settings?.autoBackupDir,
|
||||
retention: settings?.autoBackupRetention,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the backup command with settings from the project.
|
||||
* This is the main entry point for scheduled backup automation.
|
||||
*
|
||||
* NOTE: This function does NOT check autoBackupEnabled - that check should happen
|
||||
* at the automation/scheduler level. This allows manual backups via CLI even when
|
||||
* auto-backup is disabled.
|
||||
*
|
||||
* @param kbDir - Absolute path to the .kb directory
|
||||
* @param settings - Project settings
|
||||
* @returns Result of the backup operation
|
||||
*/
|
||||
export async function runBackupCommand(
|
||||
kbDir: string,
|
||||
settings: ProjectSettings
|
||||
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
|
||||
// Validate schedule if provided (for logging purposes)
|
||||
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
|
||||
return {
|
||||
success: false,
|
||||
output: `Invalid backup schedule: ${settings.autoBackupSchedule}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create backup manager with settings
|
||||
const manager = createBackupManager(kbDir, settings);
|
||||
|
||||
try {
|
||||
// Create the backup
|
||||
const backup = await manager.createBackup();
|
||||
|
||||
// Cleanup old backups
|
||||
const deletedCount = await manager.cleanupOldBackups();
|
||||
|
||||
const output = deletedCount > 0
|
||||
? `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).`
|
||||
: `Backup created: ${backup.filename} (${formatBytes(backup.size)})`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
backupPath: backup.path,
|
||||
deletedCount,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: `Backup failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats bytes as human-readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant name for the backup automation schedule.
|
||||
* Used to identify and manage the backup schedule in the automation store.
|
||||
*/
|
||||
export const BACKUP_SCHEDULE_NAME = "Database Backup";
|
||||
|
||||
/**
|
||||
* Synchronizes the backup automation schedule with project settings.
|
||||
* Creates, updates, or deletes the backup schedule based on settings.
|
||||
*
|
||||
* @param automationStore - The AutomationStore instance
|
||||
* @param settings - Current project settings
|
||||
* @returns The created/updated schedule, or undefined if deleted/disabled
|
||||
*/
|
||||
export async function syncBackupAutomation(
|
||||
automationStore: import("./automation-store.js").AutomationStore,
|
||||
settings: ProjectSettings
|
||||
): Promise<import("./automation.js").ScheduledTask | undefined> {
|
||||
const { AutomationStore } = await import("./automation-store.js");
|
||||
|
||||
// Find existing backup schedule by name
|
||||
const schedules = await automationStore.listSchedules();
|
||||
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
|
||||
|
||||
// If backups are disabled, delete existing schedule if present
|
||||
if (!settings.autoBackupEnabled) {
|
||||
if (existingSchedule) {
|
||||
await automationStore.deleteSchedule(existingSchedule.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate the cron schedule
|
||||
const schedule = settings.autoBackupSchedule || "0 2 * * *";
|
||||
if (!AutomationStore.isValidCron(schedule)) {
|
||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
||||
}
|
||||
|
||||
// Build the backup command
|
||||
// The CLI command will be: kb backup --auto
|
||||
// The --auto flag indicates this is an automated run (can add special handling if needed)
|
||||
const command = "kb backup --create";
|
||||
|
||||
if (existingSchedule) {
|
||||
// Update existing schedule
|
||||
return await automationStore.updateSchedule(existingSchedule.id, {
|
||||
scheduleType: "custom",
|
||||
cronExpression: schedule,
|
||||
command,
|
||||
enabled: true,
|
||||
});
|
||||
} else {
|
||||
// Create new schedule
|
||||
return await automationStore.createSchedule({
|
||||
name: BACKUP_SCHEDULE_NAME,
|
||||
description: "Automatic database backup based on project settings",
|
||||
scheduleType: "custom",
|
||||
cronExpression: schedule,
|
||||
command,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,15 @@ export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
|
||||
export { AutomationStore } from "./automation-store.js";
|
||||
export type { AutomationStoreEvents } from "./automation-store.js";
|
||||
export {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
generateBackupFilename,
|
||||
validateBackupSchedule,
|
||||
validateBackupRetention,
|
||||
validateBackupDir,
|
||||
runBackupCommand,
|
||||
syncBackupAutomation,
|
||||
BACKUP_SCHEDULE_NAME,
|
||||
} from "./backup.js";
|
||||
export type { BackupInfo, BackupOptions } from "./backup.js";
|
||||
|
||||
@@ -633,6 +633,14 @@ export interface ProjectSettings {
|
||||
/** When true, automatically create GitHub PRs for completed tasks.
|
||||
* Default: false. */
|
||||
autoCreatePr?: boolean;
|
||||
/** When true, automatic database backups are enabled. Default: false. */
|
||||
autoBackupEnabled?: boolean;
|
||||
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */
|
||||
autoBackupSchedule?: string;
|
||||
/** Number of backup files to retain (oldest deleted when exceeded). Default: 7. */
|
||||
autoBackupRetention?: number;
|
||||
/** Directory for backup files, relative to project root. Default: ".kb/backups". */
|
||||
autoBackupDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -689,6 +697,10 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
taskStuckTimeoutMs: undefined,
|
||||
autoUpdatePrStatus: false,
|
||||
autoCreatePr: false,
|
||||
autoBackupEnabled: false,
|
||||
autoBackupSchedule: "0 2 * * *",
|
||||
autoBackupRetention: 7,
|
||||
autoBackupDir: ".kb/backups",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -742,6 +754,10 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"taskStuckTimeoutMs",
|
||||
"autoUpdatePrStatus",
|
||||
"autoCreatePr",
|
||||
"autoBackupEnabled",
|
||||
"autoBackupSchedule",
|
||||
"autoBackupRetention",
|
||||
"autoBackupDir",
|
||||
] as const;
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -1563,3 +1563,39 @@ export function fetchAgentHeartbeats(agentId: string, limit?: number): Promise<A
|
||||
const query = limit !== undefined ? `?limit=${limit}` : "";
|
||||
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
|
||||
}
|
||||
|
||||
// --- Backup API ---
|
||||
|
||||
/** Backup metadata from the API */
|
||||
export interface BackupInfo {
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Result of listing backups */
|
||||
export interface BackupListResponse {
|
||||
backups: BackupInfo[];
|
||||
count: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/** Result of creating a backup */
|
||||
export interface BackupCreateResponse {
|
||||
success: boolean;
|
||||
backupPath?: string;
|
||||
output?: string;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Fetch all database backups */
|
||||
export function fetchBackups(): Promise<BackupListResponse> {
|
||||
return api<BackupListResponse>("/backups");
|
||||
}
|
||||
|
||||
/** Create a new database backup immediately */
|
||||
export function createBackup(): Promise<BackupCreateResponse> {
|
||||
return api<BackupCreateResponse>("/backups", { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
||||
import type { AuthProvider, ModelInfo } from "../api";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -42,6 +42,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
|
||||
{ id: "commands", label: "Commands", scope: "project" as const },
|
||||
{ id: "merge", label: "Merge", scope: "project" as const },
|
||||
{ id: "backups", label: "Backups", scope: "project" as const },
|
||||
{ id: "notifications", label: "Notifications", scope: "global" as const },
|
||||
{ id: "authentication", label: "Authentication", scope: undefined },
|
||||
] as const;
|
||||
@@ -93,6 +94,10 @@ export function SettingsModal({
|
||||
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
|
||||
const [presetIdTouched, setPresetIdTouched] = useState(false);
|
||||
|
||||
// Backup state
|
||||
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((s) => {
|
||||
@@ -125,6 +130,16 @@ export function SettingsModal({
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "backups") {
|
||||
setBackupLoading(true);
|
||||
fetchBackups()
|
||||
.then((info) => setBackupInfo(info))
|
||||
.catch(() => setBackupInfo(null))
|
||||
.finally(() => setBackupLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "authentication") {
|
||||
setAuthLoading(true);
|
||||
@@ -206,6 +221,25 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast, form.ntfyEnabled, form.ntfyTopic]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const result = await createBackup();
|
||||
if (result.success) {
|
||||
addToast("Backup created successfully", "success");
|
||||
// Refresh backup list
|
||||
const info = await fetchBackups();
|
||||
setBackupInfo(info);
|
||||
} else {
|
||||
addToast(result.error || "Failed to create backup", "error");
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create backup", "error");
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -970,6 +1004,133 @@ export function SettingsModal({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "backups":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Database Backups</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="autoBackupEnabled"
|
||||
type="checkbox"
|
||||
checked={form.autoBackupEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable automatic database backups
|
||||
</label>
|
||||
<small>When enabled, the database is backed up automatically on a schedule</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupSchedule">Backup Schedule (Cron)</label>
|
||||
<input
|
||||
id="autoBackupSchedule"
|
||||
type="text"
|
||||
placeholder="0 2 * * *"
|
||||
value={form.autoBackupSchedule || "0 2 * * *"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>
|
||||
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
|
||||
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
|
||||
</small>
|
||||
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
|
||||
<small className="field-error">Invalid cron expression format</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupRetention">Retention Count</label>
|
||||
<input
|
||||
id="autoBackupRetention"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.autoBackupRetention || 7}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupRetention: Number(e.target.value) }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>Number of backup files to keep (oldest are deleted first). Range: 1-100.</small>
|
||||
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
|
||||
<small className="field-error">Must be between 1 and 100</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupDir">Backup Directory</label>
|
||||
<input
|
||||
id="autoBackupDir"
|
||||
type="text"
|
||||
placeholder=".kb/backups"
|
||||
value={form.autoBackupDir || ".kb/backups"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>Directory for backup files, relative to project root</small>
|
||||
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
|
||||
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
|
||||
)}
|
||||
</div>
|
||||
{backupLoading ? (
|
||||
<div className="settings-empty-state">Loading backup info…</div>
|
||||
) : backupInfo ? (
|
||||
<div className="form-group">
|
||||
<label>Current Backups</label>
|
||||
<div className="backup-stats">
|
||||
<div className="backup-stat">
|
||||
<span className="backup-stat-value">{backupInfo.count}</span>
|
||||
<span className="backup-stat-label">backups</span>
|
||||
</div>
|
||||
<div className="backup-stat">
|
||||
<span className="backup-stat-value">
|
||||
{backupInfo.totalSize > 1024 * 1024
|
||||
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
|
||||
</span>
|
||||
<span className="backup-stat-label">total size</span>
|
||||
</div>
|
||||
</div>
|
||||
{backupInfo.backups.length > 0 && (
|
||||
<details className="backup-list">
|
||||
<summary>View {backupInfo.backups.length} backup(s)</summary>
|
||||
<ul>
|
||||
{backupInfo.backups.slice(0, 10).map((backup) => (
|
||||
<li key={backup.filename}>
|
||||
<code>{backup.filename}</code>
|
||||
<span className="backup-size">
|
||||
{backup.size > 1024 * 1024
|
||||
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(backup.size / 1024).toFixed(1)} KB`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{backupInfo.backups.length > 10 && (
|
||||
<li><em>...and {backupInfo.backups.length - 10} more</em></li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleBackupNow}
|
||||
disabled={backupLoading}
|
||||
>
|
||||
{backupLoading ? "Creating…" : "Backup Now"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import multer from "multer";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -1114,7 +1114,32 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
||||
}
|
||||
|
||||
// Validate backup settings if provided
|
||||
if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) {
|
||||
res.status(400).json({ error: "Invalid cron expression for autoBackupSchedule" });
|
||||
return;
|
||||
}
|
||||
if (clientSettings.autoBackupRetention !== undefined && !validateBackupRetention(clientSettings.autoBackupRetention)) {
|
||||
res.status(400).json({ error: "autoBackupRetention must be between 1 and 100" });
|
||||
return;
|
||||
}
|
||||
if (clientSettings.autoBackupDir !== undefined && !validateBackupDir(clientSettings.autoBackupDir)) {
|
||||
res.status(400).json({ error: "autoBackupDir must be a relative path without '..' traversal" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await store.updateSettings(clientSettings);
|
||||
|
||||
// Sync backup automation schedule when backup settings change
|
||||
if (options?.automationStore) {
|
||||
try {
|
||||
await syncBackupAutomation(options.automationStore, settings);
|
||||
} catch (err) {
|
||||
// Log but don't fail the settings update if automation sync fails
|
||||
console.error("Failed to sync backup automation:", err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
const status = typeof err?.message === "string" && (
|
||||
@@ -1216,6 +1241,60 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Backup Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/backups
|
||||
* List all database backups with metadata.
|
||||
*/
|
||||
router.get("/backups", async (_req, res) => {
|
||||
try {
|
||||
const { createBackupManager } = await import("@fusion/core");
|
||||
const settings = await store.getSettings();
|
||||
const manager = createBackupManager(store["kbDir"], settings);
|
||||
const backups = await manager.listBackups();
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
|
||||
|
||||
res.json({
|
||||
backups,
|
||||
count: backups.length,
|
||||
totalSize,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? "Failed to list backups" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/backups
|
||||
* Create a new database backup immediately.
|
||||
*/
|
||||
router.post("/backups", async (_req, res) => {
|
||||
try {
|
||||
const { runBackupCommand } = await import("@fusion/core");
|
||||
const settings = await store.getSettings();
|
||||
const result = await runBackupCommand(store["kbDir"], settings);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
backupPath: result.backupPath,
|
||||
output: result.output,
|
||||
deletedCount: result.deletedCount,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.output,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? "Failed to create backup" });
|
||||
}
|
||||
});
|
||||
|
||||
// Models
|
||||
registerModelsRoute(router, options?.modelRegistry);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user