Add paperclipai db:backup CLI command
This commit is contained in:
102
cli/src/commands/db-backup.ts
Normal file
102
cli/src/commands/db-backup.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { addAllowedHostname } from "./commands/allowed-hostname.js";
|
|||||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||||
import { runCommand } from "./commands/run.js";
|
import { runCommand } from "./commands/run.js";
|
||||||
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||||
|
import { dbBackupCommand } from "./commands/db-backup.js";
|
||||||
import { registerContextCommands } from "./commands/client/context.js";
|
import { registerContextCommands } from "./commands/client/context.js";
|
||||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||||
@@ -70,6 +71,19 @@ program
|
|||||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||||
.action(configure);
|
.action(configure);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("db:backup")
|
||||||
|
.description("Create a one-off database backup using current config")
|
||||||
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
|
.option("--dir <path>", "Backup output directory (overrides config)")
|
||||||
|
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
|
||||||
|
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
|
||||||
|
.option("--json", "Print backup metadata as JSON")
|
||||||
|
.action(async (opts) => {
|
||||||
|
await dbBackupCommand(opts);
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("allowed-hostname")
|
.command("allowed-hostname")
|
||||||
.description("Allow a hostname for authenticated/private mode access")
|
.description("Allow a hostname for authenticated/private mode access")
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ Configure these in:
|
|||||||
pnpm paperclipai configure --section database
|
pnpm paperclipai configure --section database
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run a one-off backup manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai db:backup
|
||||||
|
# or:
|
||||||
|
pnpm db:backup
|
||||||
|
```
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
|
|
||||||
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
|
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
exec pnpm --filter @paperclipai/db exec tsx src/backup.ts "$@"
|
exec pnpm paperclipai db:backup "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user