aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/commands
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-03-09 23:05:38 +0100
committerivar <i@oiee.no>2026-03-09 23:05:38 +0100
commit69448e29a85cad3a94b3be3ad33efbc52764528f (patch)
treec32b8c817322fdf26edbbb3fa75b9505a7020ae8 /cli/src/commands
parentb35302fa020ec82a9d67a6cb34379d42983d3cfc (diff)
downloadsparebank1-actualbudget-master.tar.xz
sparebank1-actualbudget-master.zip
Add wip cliHEADmaster
Diffstat (limited to 'cli/src/commands')
-rw-r--r--cli/src/commands/accounts.ts46
-rw-r--r--cli/src/commands/auth.ts137
-rw-r--r--cli/src/commands/backup.ts75
-rw-r--r--cli/src/commands/import.ts45
-rw-r--r--cli/src/commands/init.ts76
5 files changed, 379 insertions, 0 deletions
diff --git a/cli/src/commands/accounts.ts b/cli/src/commands/accounts.ts
new file mode 100644
index 0000000..4b7e980
--- /dev/null
+++ b/cli/src/commands/accounts.ts
@@ -0,0 +1,46 @@
+import * as p from "@clack/prompts"
+import { loadConfig, saveConfig } from "../config"
+import { createSb1Client } from "../sb1"
+import { getAccounts } from "../actual"
+
+export async function accounts() {
+ const config = loadConfig()
+ const sb1 = createSb1Client(config.sb1)
+
+ const spinner = p.spinner()
+ spinner.start("Fetching accounts...")
+ const [sb1Accounts, actualAccounts] = await Promise.all([
+ sb1.getAccounts(),
+ getAccounts(config.actual)
+ ])
+ spinner.stop("Accounts loaded.")
+
+ const openActualAccounts = actualAccounts.filter(a => !a.closed)
+
+ p.intro("Account mappings")
+
+ const mappings = []
+ for (const sb1Account of sb1Accounts) {
+ const existing = config.mappings.find(m => m.sb1Id === sb1Account.key)
+
+ const actualId = await p.select({
+ message: `${sb1Account.name} (${sb1Account.balance} ${sb1Account.currencyCode})`,
+ options: [
+ { value: null, label: "Skip" },
+ ...openActualAccounts.map(a => ({ value: a.id, label: a.name }))
+ ],
+ initialValue: existing?.actualId ?? null,
+ })
+
+ if (p.isCancel(actualId)) {
+ p.cancel("Cancelled.")
+ process.exit(0)
+ }
+
+ if (actualId) mappings.push({ sb1Id: sb1Account.key, actualId, label: sb1Account.name })
+ }
+
+ saveConfig({ ...config, mappings })
+
+ p.outro(`Saved ${mappings.length} mapping(s).`)
+}
diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts
new file mode 100644
index 0000000..70cbffb
--- /dev/null
+++ b/cli/src/commands/auth.ts
@@ -0,0 +1,137 @@
+import { randomUUID } from "node:crypto"
+import { spawn } from "node:child_process"
+import { createServer } from "node:http"
+import * as p from "@clack/prompts"
+import { Temporal } from "temporal-polyfill"
+import { loadConfig } from "../config"
+import { loadTokens, saveTokens } from "../tokens"
+import { getAccounts } from "../actual"
+import type { Sb1Tokens } from "../types"
+
+export async function auth() {
+ const config = loadConfig()
+
+ p.intro("Session status")
+
+ reportSb1Status()
+ await reportActualStatus(config)
+
+ const confirmed = await p.confirm({ message: "Re-authenticate with Sparebanken 1?" })
+ if (p.isCancel(confirmed) || !confirmed) {
+ p.outro("Done.")
+ return
+ }
+
+ const { clientId, clientSecret, finInst } = config.sb1
+ const redirectUri = "http://localhost:3123/callback"
+ const state = randomUUID()
+
+ const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize")
+ authorizeUrl.searchParams.set("client_id", clientId)
+ authorizeUrl.searchParams.set("state", state)
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri)
+ authorizeUrl.searchParams.set("finInst", finInst)
+ authorizeUrl.searchParams.set("response_type", "code")
+
+ p.log.info(`If the browser doesn't open, visit:\n ${authorizeUrl}`)
+ spawn("open", [authorizeUrl.toString()], { detached: true, stdio: "ignore" })
+
+ const spinner = p.spinner()
+ spinner.start("Waiting for callback...")
+
+ const code = await waitForCallback(3123, state)
+ const tokens = await exchangeCode(code, clientId, clientSecret, redirectUri)
+ saveTokens(tokens)
+
+ spinner.stop("SB1 authenticated.")
+
+ reportSb1Status()
+ await reportActualStatus(config)
+
+ p.outro("Done.")
+}
+
+function reportSb1Status() {
+ const stored = loadTokens()
+ if (!stored) {
+ p.log.warn("SB1: not authenticated")
+ return
+ }
+
+ const now = Temporal.Now.instant()
+
+ const accessExpiry = Temporal.Instant.fromEpochMilliseconds(stored.accessTokenCreated)
+ .add({ seconds: stored.expires_in })
+ const refreshExpiry = Temporal.Instant.fromEpochMilliseconds(stored.refreshTokenCreated)
+ .add({ seconds: stored.refresh_token_expires_in })
+
+ const accessValid = Temporal.Instant.compare(now, accessExpiry) < 0
+ const refreshValid = Temporal.Instant.compare(now, refreshExpiry) < 0
+
+ if (accessValid) {
+ p.log.success(`SB1: access token valid until ${formatInstant(accessExpiry)}`)
+ } else if (refreshValid) {
+ p.log.warn(`SB1: access token expired — refresh token valid until ${formatInstant(refreshExpiry)}`)
+ } else {
+ p.log.error(`SB1: session fully expired — re-authentication required`)
+ }
+}
+
+async function reportActualStatus(config: ReturnType<typeof loadConfig>) {
+ try {
+ const accounts = await getAccounts(config.actual)
+ const open = accounts.filter(a => !a.closed)
+ p.log.success(`Actual: connected — ${open.length} open account(s)`)
+ } catch (e: any) {
+ p.log.error(`Actual: cannot connect — ${e.message}`)
+ }
+}
+
+function formatInstant(instant: Temporal.Instant): string {
+ return instant.toZonedDateTimeISO("Europe/Oslo").toPlainDateTime().toString().replace("T", " ").slice(0, 16)
+}
+
+function waitForCallback(port: number, expectedState: string): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const server = createServer((req, res) => {
+ const url = new URL(req.url!, `http://localhost:${port}`)
+ const code = url.searchParams.get("code")
+ const state = url.searchParams.get("state")
+ const error = url.searchParams.get("error")
+
+ if (error) {
+ res.end("Authentication failed. You can close this tab.")
+ server.close()
+ reject(new Error(`Auth error: ${error}`))
+ return
+ }
+
+ if (!code || state !== expectedState) {
+ res.writeHead(400).end("Invalid callback.")
+ return
+ }
+
+ res.end("Authentication successful! You can close this tab.")
+ server.close()
+ resolve(code)
+ })
+ server.listen(port)
+ })
+}
+
+async function exchangeCode(code: string, clientId: string, clientSecret: string, redirectUri: string): Promise<Sb1Tokens> {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code,
+ redirect_uri: redirectUri,
+ grant_type: "authorization_code",
+ })
+ const res = await fetch("https://api.sparebank1.no/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: params,
+ })
+ if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`)
+ return res.json() as Promise<Sb1Tokens>
+}
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)
+}
diff --git a/cli/src/commands/import.ts b/cli/src/commands/import.ts
new file mode 100644
index 0000000..ac39edb
--- /dev/null
+++ b/cli/src/commands/import.ts
@@ -0,0 +1,45 @@
+import { Temporal } from "temporal-polyfill"
+import { loadConfig } from "../config"
+import { createSb1Client } from "../sb1"
+import { importTransactions } from "../actual"
+
+export async function runImport(args: string[]) {
+ const dryRun = args.includes("--dry-run")
+ const since = args.find(a => a.startsWith("--since="))?.split("=")[1]
+
+ if (since) {
+ try {
+ Temporal.PlainDate.from(since, { overflow: "reject" })
+ } catch {
+ throw new Error(`Invalid --since date "${since}". Expected a valid date in YYYY-MM-DD format.`)
+ }
+ }
+
+ const config = loadConfig()
+
+ if (!config.mappings.length) {
+ throw new Error("No account mappings configured. Run `sb1-actual accounts` to see available accounts, then add mappings to config.json.")
+ }
+
+ const sb1 = createSb1Client(config.sb1)
+
+ if (dryRun) console.log("Dry run — no transactions will be written.\n")
+ if (since) console.log(`Fetching transactions since ${since}\n`)
+
+ for (const mapping of config.mappings) {
+ const label = mapping.label ?? mapping.sb1Id
+ console.log(`Fetching transactions for ${label}...`)
+
+ const transactions = await sb1.getTransactions(mapping.sb1Id, since)
+ if (!transactions.length) {
+ console.log(` No transactions found.\n`)
+ continue
+ }
+
+ const booked = transactions.filter(t => t.bookingStatus === "BOOKED")
+ console.log(` ${booked.length} booked transaction(s) found.`)
+
+ const result = await importTransactions(config.actual, mapping.actualId, transactions, dryRun)
+ console.log(` Imported: ${result.added?.length ?? 0} added, ${result.updated?.length ?? 0} updated\n`)
+ }
+}
diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts
new file mode 100644
index 0000000..64b85e0
--- /dev/null
+++ b/cli/src/commands/init.ts
@@ -0,0 +1,76 @@
+import * as p from "@clack/prompts"
+import { loadConfig, saveConfig, CONFIG_PATH } from "../config"
+import type { Config } from "../config"
+
+export async function init() {
+ let existing: Partial<Config> = {}
+ try {
+ existing = loadConfig()
+ } catch {}
+
+ p.intro(existing.sb1 ? `Editing config at ${CONFIG_PATH}` : "Setting up sb1-actual")
+
+ const sb1 = await p.group({
+ clientId: () => p.text({
+ message: "SB1 client ID",
+ initialValue: existing.sb1?.clientId,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ clientSecret: () => p.text({
+ message: "SB1 client secret",
+ initialValue: existing.sb1?.clientSecret,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ finInst: async () => {
+ const known = [
+ { value: "fid-ostlandet", label: "SpareBank 1 Østlandet (fid-ostlandet)" },
+ { value: "custom", label: "Other (enter manually)" },
+ ]
+ const current = existing.sb1?.finInst
+ const selection = await p.select({
+ message: "SB1 financial institution",
+ options: known,
+ initialValue: known.find(o => o.value === current) ? current : "custom",
+ })
+ if (p.isCancel(selection)) onCancel()
+ if (selection !== "custom") return selection as string
+ return p.text({
+ message: "Enter finInst value",
+ initialValue: current,
+ validate: v => v.trim() ? undefined : "Required"
+ }) as Promise<string>
+ },
+ redirectUri: () => {
+ const uri = "http://localhost:3123/callback"
+ p.note(uri, "Redirect URI — register this in the SB1 developer portal")
+ return Promise.resolve(uri)
+ },
+ }, { onCancel })
+
+ const actual = await p.group({
+ host: () => p.text({
+ message: "Actual server URL",
+ initialValue: existing.actual?.host,
+ placeholder: "http://localhost:5006",
+ }),
+ password: () => p.password({
+ message: "Actual password",
+ }),
+ fileId: () => p.text({
+ message: "Actual file ID",
+ initialValue: existing.actual?.fileId,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ }, { onCancel })
+
+ const config: Config = { sb1, actual, mappings: existing.mappings ?? [] }
+
+ saveConfig(config)
+
+ p.outro(`Config saved. Run \`sb1-actual auth\` to authenticate with Sparebanken 1.`)
+}
+
+function onCancel() {
+ p.cancel("Cancelled.")
+ process.exit(0)
+}