diff options
| author | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
| commit | 69448e29a85cad3a94b3be3ad33efbc52764528f (patch) | |
| tree | c32b8c817322fdf26edbbb3fa75b9505a7020ae8 /cli | |
| parent | b35302fa020ec82a9d67a6cb34379d42983d3cfc (diff) | |
| download | sparebank1-actualbudget-master.tar.xz sparebank1-actualbudget-master.zip | |
Diffstat (limited to 'cli')
| -rw-r--r-- | cli/.gitignore | 34 | ||||
| -rw-r--r-- | cli/README.md | 15 | ||||
| -rw-r--r-- | cli/bun.lock | 200 | ||||
| -rw-r--r-- | cli/package.json | 22 | ||||
| -rw-r--r-- | cli/src/actual.ts | 51 | ||||
| -rw-r--r-- | cli/src/commands/accounts.ts | 46 | ||||
| -rw-r--r-- | cli/src/commands/auth.ts | 137 | ||||
| -rw-r--r-- | cli/src/commands/backup.ts | 75 | ||||
| -rw-r--r-- | cli/src/commands/import.ts | 45 | ||||
| -rw-r--r-- | cli/src/commands/init.ts | 76 | ||||
| -rw-r--r-- | cli/src/config.ts | 53 | ||||
| -rw-r--r-- | cli/src/index.ts | 39 | ||||
| -rw-r--r-- | cli/src/sb1.ts | 37 | ||||
| -rw-r--r-- | cli/src/tokens.ts | 63 | ||||
| -rw-r--r-- | cli/src/types.ts | 40 | ||||
| -rw-r--r-- | cli/tsconfig.json | 9 |
16 files changed, 942 insertions, 0 deletions
diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..cdf1245 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,15 @@ +# sb1-actual + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..18a1d0e --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,200 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "sb1-actual", + "dependencies": { + "@actual-app/api": "^26.3.0", + "@clack/prompts": "^1.1.0", + "temporal-polyfill": "^0.3.0", + }, + "devDependencies": { + "@types/node": "^22", + "tsx": "^4", + "typescript": "^5", + }, + }, + }, + "packages": { + "@actual-app/api": ["@actual-app/api@26.3.0", "", { "dependencies": { "@actual-app/crdt": "^2.1.0", "better-sqlite3": "^12.6.2", "compare-versions": "^6.1.1", "node-fetch": "^3.3.2", "uuid": "^13.0.0" } }, "sha512-03OG+udLh5GXG4I4AbfcRNhLT35vRfgHtE1JnkWGRwv6nFHY+KckPOmsDAX50fw7Q3vxPA8usHkG3JyGcRYSew=="], + + "@actual-app/crdt": ["@actual-app/crdt@2.1.0", "", { "dependencies": { "google-protobuf": "^3.12.0-rc.1", "murmurhash": "^2.0.1", "uuid": "^9.0.0" } }, "sha512-Qb8hMq10Wi2kYIDj0fG4uy00f9Mloghd+xQrHQiPQfgx022VPJ/No+z/bmfj4MuFH8FrPiLysSzRsj2PNQIedw=="], + + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "google-protobuf": ["google-protobuf@3.21.4", "", {}, "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "murmurhash": ["murmurhash@2.0.1", "", {}, "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "temporal-polyfill": ["temporal-polyfill@0.3.0", "", { "dependencies": { "temporal-spec": "0.3.0" } }, "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g=="], + + "temporal-spec": ["temporal-spec@0.3.0", "", {}, "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "@actual-app/crdt/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..bdaf9fd --- /dev/null +++ b/cli/package.json @@ -0,0 +1,22 @@ +{ + "name": "sb1-actual", + "version": "0.1.0", + "type": "module", + "bin": { + "sb1-actual": "./src/index.ts" + }, + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@actual-app/api": "^26.3.0", + "@clack/prompts": "^1.1.0", + "temporal-polyfill": "^0.3.0" + }, + "private": true, + "devDependencies": { + "@types/node": "^22", + "tsx": "^4", + "typescript": "^5" + } +} diff --git a/cli/src/actual.ts b/cli/src/actual.ts new file mode 100644 index 0000000..a236013 --- /dev/null +++ b/cli/src/actual.ts @@ -0,0 +1,51 @@ +import * as actualApi from "@actual-app/api" +import { existsSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { Temporal } from "temporal-polyfill" +import { CONFIG_DIR } from "./config" +import type { Config } from "./config" +import type { Sb1Transaction, ActualAccount } from "./types" +import type { ImportTransactionEntity } from "@actual-app/api/@types/loot-core/src/types/models/import-transaction" + +let inited = false + +export async function initActual(config: Config["actual"]) { + if (inited) return + const dataDir = join(CONFIG_DIR, "actualDataDir") + if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }) + process.env.ACTUAL_DATA_DIR = dataDir + await actualApi.init({ password: config.password, serverURL: config.host, dataDir }) + await actualApi.downloadBudget(config.fileId) + await actualApi.sync() + inited = true +} + +export async function getAccounts(config: Config["actual"]): Promise<ActualAccount[]> { + await initActual(config) + return actualApi.getAccounts() as Promise<ActualAccount[]> +} + +export async function importTransactions( + config: Config["actual"], + accountId: string, + transactions: Sb1Transaction[], + dryRun: boolean +) { + await initActual(config) + + const mapped: ImportTransactionEntity[] = transactions + .filter(t => t.bookingStatus === "BOOKED") + .map(t => ({ + account: accountId, + date: Temporal.Instant.fromEpochMilliseconds(t.date) + .toString({ timeZone: "Europe/Oslo" }) + .split("T")[0], + amount: Math.round(t.amount * 100), + payee_name: t.cleanedDescription, + notes: t.description?.toLowerCase().trim() !== t.cleanedDescription?.toLowerCase().trim() + ? t.description + : undefined + })) + + return actualApi.importTransactions(accountId, mapped, { dryRun }) +} 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) +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..a4c68a4 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,53 @@ +import { join } from "node:path" +import { homedir } from "node:os" +import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs" + +export const CONFIG_DIR = join(homedir(), ".config", "sb1-actual") +export const CONFIG_PATH = join(CONFIG_DIR, "config.json") +export const TOKENS_PATH = join(CONFIG_DIR, "tokens.json") + +export type AccountMapping = { + sb1Id: string + actualId: string + label?: string +} + +export type Config = { + sb1: { + clientId: string + clientSecret: string + finInst: string + } + actual: { + host: string + password: string + fileId: string + } + mappings: AccountMapping[] +} + +export function loadConfig(): Config { + if (!existsSync(CONFIG_PATH)) { + throw new Error(`No config found at ${CONFIG_PATH}\n\nRun \`sb1-actual init\` to create it.`) + } + return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) +} + +export function saveConfig(config: Config): void { + mkdirSync(CONFIG_DIR, { recursive: true }) + writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) +} + +const exampleConfig: Config = { + sb1: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + finInst: "YOUR_FIN_INST" + }, + actual: { + host: "http://localhost:5006", + password: "your-password", + fileId: "your-budget-file-id" + }, + mappings: [] +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..daedaae --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env tsx +import { auth } from "./commands/auth" +import { accounts } from "./commands/accounts" +import { runImport } from "./commands/import" +import { init } from "./commands/init" +import { backup, restore } from "./commands/backup" + +const [command, ...args] = process.argv.slice(2) + +const commands: Record<string, (args: string[]) => Promise<void>> = { + init: () => init(), + auth: () => auth(), + accounts: () => accounts(), + import: (args) => runImport(args), + backup: () => backup(), + restore: (args) => restore(args), +} + +const handler = commands[command] + +if (!handler) { + console.log("Usage: sb1-actual <command> [options]") + console.log("") + console.log("Commands:") + console.log(" init Create or edit config") + console.log(" auth Authenticate with Sparebanken 1 (opens browser)") + console.log(" accounts List accounts from SB1 and Actual, show mappings") + console.log(" import Import transactions into Actual") + console.log(" import --dry-run Preview import without writing") + console.log(" import --since=YYYY-MM-DD Only fetch transactions from this date") + console.log(" backup Export budget to ~/.config/sb1-actual/backups/") + console.log(" restore Restore budget from a backup") + process.exit(1) +} + +handler(args).catch(err => { + console.error(`Error: ${err.message}`) + process.exit(1) +}) diff --git a/cli/src/sb1.ts b/cli/src/sb1.ts new file mode 100644 index 0000000..36c638c --- /dev/null +++ b/cli/src/sb1.ts @@ -0,0 +1,37 @@ +import { getAccessToken } from "./tokens" +import type { Config } from "./config" +import type { Sb1Account, Sb1Transaction } from "./types" + +export function createSb1Client(config: Config["sb1"]) { + async function token() { + return getAccessToken(config.clientId, config.clientSecret) + } + + return { + async getAccounts(): Promise<Sb1Account[]> { + const res = await fetch("https://api.sparebank1.no/personal/banking/accounts", { + headers: { Authorization: `Bearer ${await token()}` } + }) + if (!res.ok) throw new Error(`Failed to fetch accounts: ${await res.text()}`) + const json = await res.json() as { accounts: Sb1Account[] } + return json.accounts + }, + + async getTransactions(accountKey: string, fromDate?: string): Promise<Sb1Transaction[]> { + const params = new URLSearchParams({ accountKey }) + if (fromDate) { + params.set("fromDate", fromDate) + params.set("Transaction source", "ALL") + } + const res = await fetch(`https://api.sparebank1.no/personal/banking/transactions?${params}`, { + headers: { + Authorization: `Bearer ${await token()}`, + Accept: "application/vnd.sparebank1.v1+json;charset=utf-8" + } + }) + if (!res.ok) throw new Error(`Failed to fetch transactions: ${await res.text()}`) + const json = await res.json() + return json["transactions"] as Sb1Transaction[] + } + } +} diff --git a/cli/src/tokens.ts b/cli/src/tokens.ts new file mode 100644 index 0000000..b1918d2 --- /dev/null +++ b/cli/src/tokens.ts @@ -0,0 +1,63 @@ +import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs" +import { Temporal } from "temporal-polyfill" +import { CONFIG_DIR, TOKENS_PATH } from "./config" +import type { Sb1Tokens } from "./types" + +type StoredTokens = Sb1Tokens & { + accessTokenCreated: number + refreshTokenCreated: number +} + +export function loadTokens(): StoredTokens | null { + if (!existsSync(TOKENS_PATH)) return null + return JSON.parse(readFileSync(TOKENS_PATH, "utf8")) +} + +export function saveTokens(tokens: Sb1Tokens): void { + mkdirSync(CONFIG_DIR, { recursive: true }) + const now = Temporal.Now.instant().epochMilliseconds + const stored: StoredTokens = { ...tokens, accessTokenCreated: now, refreshTokenCreated: now } + writeFileSync(TOKENS_PATH, JSON.stringify(stored, null, 2)) +} + +export async function getAccessToken(clientId: string, clientSecret: string): Promise<string> { + const stored = loadTokens() + if (!stored) throw new Error("Not authenticated. Run `sb1-actual auth` first.") + + const now = Temporal.Now.instant() + + const accessExpiry = Temporal.Instant.fromEpochMilliseconds(stored.accessTokenCreated) + .add({ seconds: stored.expires_in }) + + if (Temporal.Instant.compare(now, accessExpiry) < 0) { + return stored.access_token + } + + const refreshExpiry = Temporal.Instant.fromEpochMilliseconds(stored.refreshTokenCreated) + .add({ seconds: stored.refresh_token_expires_in }) + + if (Temporal.Instant.compare(now, refreshExpiry) >= 0) { + throw new Error("Session expired. Run `sb1-actual auth` again.") + } + + return refreshAccessToken(stored.refresh_token, clientId, clientSecret) +} + +async function refreshAccessToken(refreshToken: string, clientId: string, clientSecret: string): Promise<string> { + console.log("Refreshing access token...") + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }) + 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 refresh failed: ${await res.text()}`) + const tokens = await res.json() as Sb1Tokens + saveTokens(tokens) + return tokens.access_token +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..eea7c9b --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,40 @@ +export type Sb1Tokens = { + access_token: string + expires_in: number + refresh_token_expires_in: number + refresh_token_absolute_expires_in: number + token_type: string + refresh_token: string +} + +export type Sb1Account = { + key: string + accountNumber: string + iban: string + name: string + balance: number + availableBalance: number + currencyCode: string +} + +export type Sb1Transaction = { + id: string + nonUniqueId: string + description: string + cleanedDescription: string + amount: number + date: number + typeCode: string + typeText: string + currencyCode: string + bookingStatus: string + accountName: string + accountKey: string +} + +export type ActualAccount = { + id: string + name: string + type: string + closed: boolean +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..12cd9d9 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "types": ["node"] + } +} |
