aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/commands/backup.ts
diff options
context:
space:
mode:
Diffstat (limited to 'cli/src/commands/backup.ts')
-rw-r--r--cli/src/commands/backup.ts75
1 files changed, 75 insertions, 0 deletions
diff --git a/cli/src/commands/backup.ts b/cli/src/commands/backup.ts
new file mode 100644
index 0000000..4a7e4f0
--- /dev/null
+++ b/cli/src/commands/backup.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, readdirSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import * as p from "@clack/prompts"
+import * as actualApi from "@actual-app/api"
+import { CONFIG_DIR, loadConfig } from "../config"
+import { initActual } from "../actual"
+
+const BACKUP_DIR = join(CONFIG_DIR, "backups")
+
+export async function backup() {
+ const config = loadConfig()
+
+ const spinner = p.spinner()
+ spinner.start("Connecting to Actual...")
+ await initActual(config.actual)
+ spinner.stop("Connected.")
+
+ spinner.start("Exporting budget...")
+ const result = await (actualApi as any).internal.send("export-budget")
+ if (result.error) throw new Error(`Export failed: ${result.error}`)
+
+ mkdirSync(BACKUP_DIR, { recursive: true })
+ const filename = `backup-${timestamp()}.zip`
+ const filepath = join(BACKUP_DIR, filename)
+ writeFileSync(filepath, result.data)
+
+ spinner.stop(`Backup saved to ${filepath}`)
+}
+
+export async function restore(args: string[]) {
+ const config = loadConfig()
+
+ const backups = listBackups()
+ if (!backups.length) {
+ p.log.warn(`No backups found in ${BACKUP_DIR}`)
+ return
+ }
+
+ const filepath = await p.select({
+ message: "Select backup to restore",
+ options: backups.map(f => ({ value: join(BACKUP_DIR, f), label: f }))
+ })
+ if (p.isCancel(filepath)) { p.cancel("Cancelled."); process.exit(0) }
+
+ const confirmed = await p.confirm({
+ message: `Restore ${filepath}? This will overwrite your current budget.`
+ })
+ if (p.isCancel(confirmed) || !confirmed) { p.cancel("Cancelled."); process.exit(0) }
+
+ const spinner = p.spinner()
+ spinner.start("Connecting to Actual...")
+ await initActual(config.actual)
+
+ spinner.start("Restoring backup...")
+ const result = await (actualApi as any).internal.send("import-budget", { filepath, type: "actual" })
+ if (result?.error) throw new Error(`Restore failed: ${result.error}`)
+
+ await actualApi.sync()
+ spinner.stop("Restored and synced.")
+}
+
+function listBackups(): string[] {
+ try {
+ return readdirSync(BACKUP_DIR)
+ .filter(f => f.endsWith(".zip"))
+ .sort()
+ .reverse()
+ } catch {
+ return []
+ }
+}
+
+function timestamp(): string {
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
+}