From ec0b7daca2456146aa5b122353452822735f9b68 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 06:02:12 -0600 Subject: [PATCH] Add paperclipai db:backup CLI command --- cli/src/commands/db-backup.ts | 102 ++++++++++++++++++++++++++++++++++ cli/src/index.ts | 14 +++++ doc/DEVELOPING.md | 8 +++ scripts/backup-db.sh | 2 +- 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/db-backup.ts diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts new file mode 100644 index 00000000..bdbf739f --- /dev/null +++ b/cli/src/commands/db-backup.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { formatDatabaseBackupResult, runDatabaseBackup } from "@paperclipai/db"; +import { + expandHomePrefix, + resolveDefaultBackupDir, + resolvePaperclipInstanceId, +} from "../config/home.js"; +import { readConfig, resolveConfigPath } from "../config/store.js"; +import { printPaperclipCliBanner } from "../utils/banner.js"; + +type DbBackupOptions = { + config?: string; + dir?: string; + retentionDays?: number; + filenamePrefix?: string; + json?: boolean; +}; + +function resolveConnectionString(configPath?: string): { value: string; source: string } { + const envUrl = process.env.DATABASE_URL?.trim(); + if (envUrl) return { value: envUrl, source: "DATABASE_URL" }; + + const config = readConfig(configPath); + if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) { + return { value: config.database.connectionString.trim(), source: "config.database.connectionString" }; + } + + const port = config?.database.embeddedPostgresPort ?? 54329; + return { + value: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`, + source: `embedded-postgres@${port}`, + }; +} + +function normalizeRetentionDays(value: number | undefined, fallback: number): number { + const candidate = value ?? fallback; + if (!Number.isInteger(candidate) || candidate < 1) { + throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`); + } + return candidate; +} + +function resolveBackupDir(raw: string): string { + return path.resolve(expandHomePrefix(raw.trim())); +} + +export async function dbBackupCommand(opts: DbBackupOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclip db:backup "))); + + const configPath = resolveConfigPath(opts.config); + const config = readConfig(opts.config); + const connection = resolveConnectionString(opts.config); + const defaultDir = resolveDefaultBackupDir(resolvePaperclipInstanceId()); + const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir; + const backupDir = resolveBackupDir(configuredDir); + const retentionDays = normalizeRetentionDays( + opts.retentionDays, + config?.database.backup.retentionDays ?? 30, + ); + const filenamePrefix = opts.filenamePrefix?.trim() || "paperclip"; + + p.log.message(pc.dim(`Config: ${configPath}`)); + p.log.message(pc.dim(`Connection source: ${connection.source}`)); + p.log.message(pc.dim(`Backup dir: ${backupDir}`)); + p.log.message(pc.dim(`Retention: ${retentionDays} day(s)`)); + + const spinner = p.spinner(); + spinner.start("Creating database backup..."); + try { + const result = await runDatabaseBackup({ + connectionString: connection.value, + backupDir, + retentionDays, + filenamePrefix, + }); + spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); + + if (opts.json) { + console.log( + JSON.stringify( + { + backupFile: result.backupFile, + sizeBytes: result.sizeBytes, + prunedCount: result.prunedCount, + backupDir, + retentionDays, + connectionSource: connection.source, + }, + null, + 2, + ), + ); + } + p.outro(pc.green("Backup completed.")); + } catch (err) { + spinner.stop(pc.red("Backup failed.")); + throw err; + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 65a8fdcc..9c31f5ae 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -7,6 +7,7 @@ import { addAllowedHostname } from "./commands/allowed-hostname.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; import { runCommand } from "./commands/run.js"; import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js"; +import { dbBackupCommand } from "./commands/db-backup.js"; import { registerContextCommands } from "./commands/client/context.js"; import { registerCompanyCommands } from "./commands/client/company.js"; import { registerIssueCommands } from "./commands/client/issue.js"; @@ -70,6 +71,19 @@ program .option("-s, --section
", "Section to configure (llm, database, logging, server, storage, secrets)") .action(configure); +program + .command("db:backup") + .description("Create a one-off database backup using current config") + .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) + .option("--dir ", "Backup output directory (overrides config)") + .option("--retention-days ", "Retention window used for pruning", (value) => Number(value)) + .option("--filename-prefix ", "Backup filename prefix", "paperclip") + .option("--json", "Print backup metadata as JSON") + .action(async (opts) => { + await dbBackupCommand(opts); + }); + program .command("allowed-hostname") .description("Allow a hostname for authenticated/private mode access") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 65774b5d..debc88f3 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -156,6 +156,14 @@ Configure these in: pnpm paperclipai configure --section database ``` +Run a one-off backup manually: + +```sh +pnpm paperclipai db:backup +# or: +pnpm db:backup +``` + Environment overrides: - `PAPERCLIP_DB_BACKUP_ENABLED=true|false` diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index d77c922d..abaeeeda 100755 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -14,4 +14,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" -exec pnpm --filter @paperclipai/db exec tsx src/backup.ts "$@" +exec pnpm paperclipai db:backup "$@"