From 69448e29a85cad3a94b3be3ad33efbc52764528f Mon Sep 17 00:00:00 2001 From: ivar Date: Mon, 9 Mar 2026 23:05:38 +0100 Subject: Add wip cli --- cli/src/commands/accounts.ts | 46 +++++++++++++++ cli/src/commands/auth.ts | 137 +++++++++++++++++++++++++++++++++++++++++++ cli/src/commands/backup.ts | 75 +++++++++++++++++++++++ cli/src/commands/import.ts | 45 ++++++++++++++ cli/src/commands/init.ts | 76 ++++++++++++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 cli/src/commands/accounts.ts create mode 100644 cli/src/commands/auth.ts create mode 100644 cli/src/commands/backup.ts create mode 100644 cli/src/commands/import.ts create mode 100644 cli/src/commands/init.ts (limited to 'cli/src/commands') 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) { + 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 { + 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 { + 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 +} 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 = {} + 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 + }, + 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) +} -- cgit v1.3