simplefin-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PLAN.md ADDED
@@ -0,0 +1,104 @@
1
+ # SimpleFIN CLI Plan
2
+
3
+ ## Overview
4
+ CLI tool for accessing financial data via SimpleFIN Bridge (multi-bank support)
5
+
6
+ ## Author
7
+ dilllxd
8
+
9
+ ## License
10
+ MIT + Commons Clause
11
+
12
+ ## Module Path
13
+ github.com/dilllxd/simplefin-cli
14
+
15
+ ## Dependencies
16
+ - native Go `net/http`
17
+ - charm.sh/gum for pretty output
18
+ - viper for config (TOML)
19
+
20
+ ## Config Location
21
+ ~/.config/simplefin-cli/config.toml
22
+
23
+ ## Multi-Account Support
24
+ No - single account, but links multiple banks internally
25
+
26
+ ## Commands
27
+
28
+ ### Connection
29
+ ```bash
30
+ simplefin connect # Interactive setup token prompt
31
+ simplefin connect --token "..." # Non-interactive
32
+ simplefin status # Check connection status
33
+ simplefin disconnect # Remove all bank connections
34
+ ```
35
+
36
+ ### Accounts
37
+ ```bash
38
+ simplefin accounts list # List all linked accounts
39
+ simplefin accounts get <account-id> # Get account details
40
+ simplefin accounts balances # Get all current balances
41
+ ```
42
+
43
+ ### Transactions
44
+ ```bash
45
+ simplefin transactions # Recent transactions (default 30 days)
46
+ simplefin transactions --days 7
47
+ simplefin transactions --days 90
48
+ simplefin transactions --account <account-id>
49
+ simplefin transactions --format json
50
+ simplefin transactions-by-date --start 2026-01-01 --end 2026-01-31
51
+ ```
52
+
53
+ ### Sync
54
+ ```bash
55
+ simplefin sync # Force refresh from all banks
56
+ simplefin sync --account <account-id> # Refresh specific account
57
+ ```
58
+
59
+ ## Config File
60
+ ```toml
61
+ [connection]
62
+ setup_token = "${SIMPLEFIN_SETUP_TOKEN}"
63
+ access_url = "${SIMPLEFIN_ACCESS_URL}" # Optional, overrides token
64
+
65
+ [output]
66
+ format = "table"
67
+ colors = true
68
+ ```
69
+
70
+ ## Environment Variables
71
+ - SIMPLEFIN_SETUP_TOKEN
72
+ - SIMPLEFIN_ACCESS_URL
73
+
74
+ ## Storage
75
+ ~/.config/simplefin-cli/connection.json:
76
+ ```json
77
+ {
78
+ "access_url": "https://bridge.simplefin.org/simplefin/...",
79
+ "created_at": "2026-01-10T00:00:00Z",
80
+ "last_sync": "2026-01-10T12:00:00Z"
81
+ }
82
+ ```
83
+
84
+ ## Auth Flow
85
+ 1. User visits https://bridge.simplefin.org/simplefin/create
86
+ 2. Gets a setup token
87
+ 3. Runs `simplefin connect --token "..."`
88
+ 4. CLI exchanges setup token for access URL
89
+ 5. Access URL stored for future use
90
+
91
+ ## Data Types
92
+ - Account (name, type, balance, currency, institution)
93
+ - Transaction (date, amount, description, category, account)
94
+
95
+ ## Implementation Phases
96
+ 1. Config + connection flow
97
+ 2. Account listing and balance retrieval
98
+ 3. Transaction retrieval (by days and date range)
99
+ 4. Sync/refresh functionality
100
+
101
+ ## Testing
102
+ - Unit tests for API client
103
+ - Integration tests with real SimpleFIN connection
104
+ - Mock server for testing
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # SimpleFIN CLI
2
+
3
+ CLI tool for accessing financial data via SimpleFIN Bridge.
4
+
5
+ ## Author
6
+ dilllxd
7
+
8
+ ## License
9
+ MIT
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g simplefin-cli
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Connect to SimpleFIN Bridge
20
+
21
+ ```bash
22
+ simplefin connect
23
+ ```
24
+
25
+ You'll need a setup token from https://bridge.simplefin.org/simplefin/create
26
+
27
+ ### List Accounts
28
+
29
+ ```bash
30
+ simplefin accounts
31
+ ```
32
+
33
+ ### View Transactions
34
+
35
+ ```bash
36
+ simplefin transactions # Last 30 days
37
+ simplefin transactions --days 7 # Last 7 days
38
+ simplefin transactions --json # JSON output
39
+ ```
40
+
41
+ ### Disconnect
42
+
43
+ ```bash
44
+ simplefin disconnect
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `simplefin connect` | Connect to SimpleFIN Bridge |
52
+ | `simplefin disconnect` | Disconnect and remove credentials |
53
+ | `simplefin status` | Check connection status |
54
+ | `simplefin accounts` | List connected accounts |
55
+ | `simplefin transactions` | List recent transactions |
56
+
57
+ ## Options
58
+
59
+ | Option | Description |
60
+ |--------|-------------|
61
+ | `--json` | Output as JSON (for all commands) |
62
+ | `-d, --days` | Number of days to look back (for transactions) |
63
+ | `-t, --token` | Setup token (for connect) |
64
+
65
+ ## Environment Variables
66
+
67
+ - `SIMPLEFIN_SETUP_TOKEN` - Setup token for connect command
68
+
69
+ ## Configuration
70
+
71
+ Config stored in `~/.config/simplefin-cli/connection.json`
72
+
73
+ ## Publishing to npm
74
+
75
+ ```bash
76
+ # Log in to npm
77
+ npm login
78
+
79
+ # Publish
80
+ cd simplefin-cli
81
+ npm publish
82
+ ```
83
+
84
+ ## Testing
85
+
86
+ ```bash
87
+ node test.js
88
+ ```
@@ -0,0 +1,442 @@
1
+ const { Command } = require('commander');
2
+ const axios = require('axios');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const program = new Command();
8
+
9
+ function getConfigDir() {
10
+ return path.join(os.homedir(), '.config', 'simplefin-cli');
11
+ }
12
+
13
+ function getConnectionPath() {
14
+ return path.join(getConfigDir(), 'connection.json');
15
+ }
16
+
17
+ function loadConnection() {
18
+ try {
19
+ const data = fs.readFileSync(getConnectionPath(), 'utf-8');
20
+ return JSON.parse(data);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function saveConnection(accessUrl, username, password) {
27
+ fs.mkdirSync(getConfigDir(), { recursive: true });
28
+ const connection = {
29
+ access_url: accessUrl,
30
+ username,
31
+ password,
32
+ created_at: new Date().toISOString(),
33
+ };
34
+ fs.writeFileSync(getConnectionPath(), JSON.stringify(connection, null, 2));
35
+ }
36
+
37
+ function deleteConnection() {
38
+ try {
39
+ fs.unlinkSync(getConnectionPath());
40
+ } catch (e) {
41
+ // ignore
42
+ }
43
+ }
44
+
45
+ function parseAuthFromUrl(accessUrl) {
46
+ const match = accessUrl.match(/:\/\/([^:]+):([^@]+)@/);
47
+ if (match) {
48
+ return {
49
+ username: match[1],
50
+ password: match[2],
51
+ baseUrl: accessUrl.replace(/:\/\/[^:]+:[^@]+@/, '://'),
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ async function claimAccessUrl(setupToken) {
58
+ const claimUrl = Buffer.from(setupToken, 'base64').toString('utf-8');
59
+
60
+ const response = await axios.post(claimUrl, '', {
61
+ headers: { 'Content-Length': '0' },
62
+ timeout: 10000,
63
+ });
64
+
65
+ if (response.status === 403) {
66
+ throw new Error('Token is invalid or has already been used');
67
+ }
68
+
69
+ if (response.status !== 200) {
70
+ throw new Error(`Unexpected response: ${response.status}`);
71
+ }
72
+
73
+ const accessUrl = response.data.trim();
74
+ if (!accessUrl) {
75
+ throw new Error('Empty response from server');
76
+ }
77
+
78
+ return accessUrl;
79
+ }
80
+
81
+ async function getInfo(baseUrl) {
82
+ const response = await axios.get(`${baseUrl}/info`);
83
+ return response.data;
84
+ }
85
+
86
+ async function getAccounts(options) {
87
+ const params = new URLSearchParams();
88
+
89
+ if (options.startDate) params.append('start-date', options.startDate);
90
+ if (options.endDate) params.append('end-date', options.endDate);
91
+ if (options.pending) params.append('pending', '1');
92
+ if (options.account) params.append('account', options.account);
93
+ if (options.balancesOnly) params.append('balances-only', '1');
94
+
95
+ const url = `${options.baseUrl}/accounts${params.toString() ? '?' + params.toString() : ''}`;
96
+
97
+ const response = await axios.get(url, {
98
+ auth: { username: options.username, password: options.password },
99
+ });
100
+
101
+ if (response.status === 403) {
102
+ throw new Error('Authentication failed - access may be revoked');
103
+ }
104
+
105
+ if (response.status === 402) {
106
+ throw new Error('Payment required');
107
+ }
108
+
109
+ return response.data;
110
+ }
111
+
112
+ function formatDate(timestamp) {
113
+ if (!timestamp) return 'N/A';
114
+ const date = new Date(timestamp * 1000);
115
+ return date.toLocaleString('en-US', {
116
+ month: 'short', day: 'numeric', year: 'numeric',
117
+ hour: 'numeric', minute: '2-digit', hour12: true,
118
+ });
119
+ }
120
+
121
+ function formatDateFull(timestamp) {
122
+ if (!timestamp) return 'N/A';
123
+ const date = new Date(timestamp * 1000);
124
+ return date.toLocaleString('en-US', {
125
+ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
126
+ hour: 'numeric', minute: '2-digit', hour12: true,
127
+ });
128
+ }
129
+
130
+ function formatDateShort(timestamp) {
131
+ if (!timestamp) return '';
132
+ const date = new Date(timestamp * 1000);
133
+ return date.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit' });
134
+ }
135
+
136
+ function truncate(str, len) {
137
+ if (!str) return '';
138
+ return str.length > len ? str.slice(0, len - 3) + '...' : str;
139
+ }
140
+
141
+ program.name('simplefin').description('SimpleFIN CLI - Access your financial data');
142
+
143
+ // ===== INFO COMMAND =====
144
+ program.command('info')
145
+ .description('Get SimpleFIN server information')
146
+ .option('--json', 'Output as JSON')
147
+ .action(async (options) => {
148
+ const conn = loadConnection();
149
+ if (!conn) {
150
+ console.log('Not connected');
151
+ console.log("Run 'simplefin connect' first");
152
+ process.exit(1);
153
+ }
154
+
155
+ try {
156
+ const data = await getInfo(conn.access_url);
157
+
158
+ if (options.json) {
159
+ console.log(JSON.stringify(data, null, 2));
160
+ return;
161
+ }
162
+
163
+ console.log('Server Info');
164
+ console.log('-----------');
165
+ console.log(`Supported versions: ${(data.versions || []).join(', ')}`);
166
+ } catch (error) {
167
+ console.error('Error:', error.message);
168
+ process.exit(1);
169
+ }
170
+ });
171
+
172
+ // ===== CONNECT COMMAND =====
173
+ program.command('connect')
174
+ .description('Connect to SimpleFIN Bridge')
175
+ .option('-t, --token <token>', 'Setup token (or use SIMPLEFIN_SETUP_TOKEN env var)')
176
+ .action(async (options) => {
177
+ let setupToken = options.token || process.env.SIMPLEFIN_SETUP_TOKEN;
178
+
179
+ if (!setupToken) {
180
+ console.log('Error: Setup token required');
181
+ console.log('Provide via:');
182
+ console.log(' -t, --token option: simplefin connect --token <token>');
183
+ console.log(' Environment variable: export SIMPLEFIN_SETUP_TOKEN=<token>');
184
+ console.log('\nGet a token at: https://bridge.simplefin.org/simplefin/create');
185
+ process.exit(1);
186
+ }
187
+
188
+ console.log('Connecting to SimpleFIN Bridge...');
189
+
190
+ try {
191
+ const accessUrl = await claimAccessUrl(setupToken);
192
+ const auth = parseAuthFromUrl(accessUrl);
193
+ if (!auth) {
194
+ throw new Error('Could not parse credentials from Access URL');
195
+ }
196
+ saveConnection(auth.baseUrl, auth.username, auth.password);
197
+ console.log('Connected successfully!');
198
+ console.log('\nNext steps:');
199
+ console.log(' simplefin info - Server info');
200
+ console.log(' simplefin accounts - List your accounts');
201
+ console.log(' simplefin transactions - View transactions');
202
+ } catch (error) {
203
+ console.error('Error:', error.message);
204
+ process.exit(1);
205
+ }
206
+ });
207
+
208
+ // ===== DISCONNECT COMMAND =====
209
+ program.command('disconnect')
210
+ .description('Disconnect from SimpleFIN Bridge')
211
+ .action(() => {
212
+ deleteConnection();
213
+ console.log('Disconnected successfully');
214
+ });
215
+
216
+ // ===== STATUS COMMAND =====
217
+ program.command('status')
218
+ .description('Check connection status')
219
+ .option('--json', 'Output as JSON')
220
+ .action((options) => {
221
+ const conn = loadConnection();
222
+ if (!conn) {
223
+ console.log('Not connected');
224
+ console.log("Run 'simplefin connect' first");
225
+ process.exit(1);
226
+ }
227
+
228
+ if (options.json) {
229
+ console.log(JSON.stringify({
230
+ connected: true,
231
+ server: conn.access_url,
232
+ created_at: conn.created_at,
233
+ }, null, 2));
234
+ return;
235
+ }
236
+
237
+ console.log('Connection Status');
238
+ console.log('-----------------');
239
+ console.log('Connected');
240
+ console.log(`Server: ${conn.access_url}`);
241
+ console.log(`Connected since: ${formatDate(new Date(conn.created_at).getTime() / 1000)}`);
242
+ });
243
+
244
+ // ===== ACCOUNTS COMMAND =====
245
+ program.command('accounts')
246
+ .description('List all connected accounts')
247
+ .option('--json', 'Output as JSON')
248
+ .option('--balances-only', 'Only show balances, no transactions')
249
+ .action(async (options) => {
250
+ const conn = loadConnection();
251
+ if (!conn) {
252
+ console.log('Not connected');
253
+ process.exit(1);
254
+ }
255
+
256
+ try {
257
+ const data = await getAccounts({
258
+ baseUrl: conn.access_url,
259
+ username: conn.username,
260
+ password: conn.password,
261
+ balancesOnly: options.balancesOnly,
262
+ });
263
+
264
+ if (data.errors && data.errors.length > 0) {
265
+ console.log('Errors from server:');
266
+ for (const err of data.errors) {
267
+ console.log(` - ${err}`);
268
+ }
269
+ console.log('');
270
+ }
271
+
272
+ const accounts = data.accounts || [];
273
+
274
+ if (options.json) {
275
+ console.log(JSON.stringify({
276
+ connected: true,
277
+ errors: data.errors || [],
278
+ accounts: accounts.map(acc => ({
279
+ id: acc.id,
280
+ name: acc.name,
281
+ organization: acc.org?.name || acc.org?.domain || 'Unknown',
282
+ balance: acc.balance,
283
+ 'available-balance': acc['available-balance'],
284
+ currency: acc.currency,
285
+ 'balance-date': acc['balance-date'],
286
+ type: acc.type,
287
+ extra: acc.extra,
288
+ })),
289
+ total_accounts: accounts.length,
290
+ }, null, 2));
291
+ return;
292
+ }
293
+
294
+ console.log('Accounts');
295
+ console.log('--------');
296
+ for (const acc of accounts) {
297
+ console.log(`${acc.name} (${acc.org?.name || acc.org?.domain || 'Unknown'})`);
298
+ console.log(` Balance: ${acc.balance} ${acc.currency}`);
299
+ if (acc['available-balance'] && acc['available-balance'] !== acc.balance) {
300
+ console.log(` Available: ${acc['available-balance']} ${acc.currency}`);
301
+ }
302
+ console.log(` Last updated: ${formatDate(acc['balance-date'])}`);
303
+ if (acc.type) {
304
+ console.log(` Type: ${acc.type}`);
305
+ }
306
+ if (acc.transactions && acc.transactions.length > 0 && !options.balancesOnly) {
307
+ console.log(` Transactions: ${acc.transactions.length}`);
308
+ }
309
+ }
310
+ console.log('--------');
311
+ console.log(`Total: ${accounts.length} account(s)`);
312
+ } catch (error) {
313
+ console.error('Error:', error.message);
314
+ process.exit(1);
315
+ }
316
+ });
317
+
318
+ // ===== TRANSACTIONS COMMAND =====
319
+ program.command('transactions')
320
+ .description('List transactions')
321
+ .option('-d, --days <days>', 'Number of days to look back', '30')
322
+ .option('--start-date <date>', 'Start date (Unix timestamp or YYYY-MM-DD)')
323
+ .option('--end-date <date>', 'End date (Unix timestamp or YYYY-MM-DD)')
324
+ .option('--account <id>', 'Filter by account ID')
325
+ .option('--pending', 'Include pending transactions')
326
+ .option('--json', 'Output as JSON')
327
+ .action(async (options) => {
328
+ const conn = loadConnection();
329
+ if (!conn) {
330
+ console.log('Not connected');
331
+ process.exit(1);
332
+ }
333
+
334
+ // Parse dates
335
+ let startDate, endDate;
336
+
337
+ if (options.startDate) {
338
+ if (options.startDate.includes('-')) {
339
+ startDate = Math.floor(new Date(options.startDate).getTime() / 1000);
340
+ } else {
341
+ startDate = parseInt(options.startDate);
342
+ }
343
+ } else {
344
+ const days = parseInt(options.days) || 30;
345
+ startDate = Math.floor(Date.now() / 1000) - (days * 24 * 60 * 60);
346
+ }
347
+
348
+ if (options.endDate) {
349
+ if (options.endDate.includes('-')) {
350
+ endDate = Math.floor(new Date(options.endDate).getTime() / 1000);
351
+ } else {
352
+ endDate = parseInt(options.endDate);
353
+ }
354
+ }
355
+
356
+ try {
357
+ const data = await getAccounts({
358
+ baseUrl: conn.access_url,
359
+ username: conn.username,
360
+ password: conn.password,
361
+ startDate: startDate.toString(),
362
+ endDate: endDate?.toString(),
363
+ account: options.account,
364
+ pending: options.pending,
365
+ });
366
+
367
+ if (data.errors && data.errors.length > 0) {
368
+ console.log('Errors from server:');
369
+ for (const err of data.errors) {
370
+ console.log(` - ${err}`);
371
+ }
372
+ console.log('');
373
+ }
374
+
375
+ const accounts = data.accounts || [];
376
+ const allTransactions = [];
377
+
378
+ for (const acc of accounts) {
379
+ for (const tx of acc.transactions || []) {
380
+ allTransactions.push({
381
+ ...tx,
382
+ account_name: acc.name,
383
+ account_id: acc.id,
384
+ });
385
+ }
386
+ }
387
+
388
+ allTransactions.sort((a, b) => b.posted - a.posted);
389
+
390
+ if (options.json) {
391
+ console.log(JSON.stringify({
392
+ connected: true,
393
+ errors: data.errors || [],
394
+ query: {
395
+ start_date: startDate,
396
+ end_date: endDate,
397
+ account: options.account,
398
+ pending: options.pending,
399
+ },
400
+ transactions: allTransactions,
401
+ count: allTransactions.length,
402
+ }, null, 2));
403
+ return;
404
+ }
405
+
406
+ console.log('Transactions');
407
+ console.log('------------');
408
+
409
+ if (startDate && !options.startDate) {
410
+ const days = parseInt(options.days) || 30;
411
+ console.log(`From last ${days} days`);
412
+ } else if (startDate) {
413
+ console.log(`From ${formatDate(startDate)}`);
414
+ if (endDate) console.log(`To ${formatDate(endDate)}`);
415
+ }
416
+
417
+ console.log(`Found ${allTransactions.length} transaction(s)\n`);
418
+
419
+ for (const tx of allTransactions) {
420
+ const amount = parseFloat(tx.amount).toFixed(2);
421
+ const sign = tx.amount.startsWith('-') ? '' : '+';
422
+ const pending = tx.pending ? '[PENDING] ' : '';
423
+ const date = tx.posted ? formatDateFull(tx.posted) : 'N/A';
424
+
425
+ console.log(`${date}`);
426
+ console.log(` ${sign}${amount.padStart(10)} ${pending}${truncate(tx.description, 50)}`);
427
+ console.log(` Account: ${tx.account_name}`);
428
+ if (tx.transacted_at && tx.transacted_at !== tx.posted) {
429
+ console.log(` Transacted: ${formatDateFull(tx.transacted_at)}`);
430
+ }
431
+ if (tx.category || (tx.extra && tx.extra.category)) {
432
+ console.log(` Category: ${tx.category || tx.extra.category}`);
433
+ }
434
+ console.log('');
435
+ }
436
+ } catch (error) {
437
+ console.error('Error:', error.message);
438
+ process.exit(1);
439
+ }
440
+ });
441
+
442
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "simplefin-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for accessing financial data via SimpleFIN Bridge",
5
+ "author": "dilllxd",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://git.dylan.lol/dylan/cli-tools"
10
+ },
11
+ "bugs": {
12
+ "url": "https://git.dylan.lol/dylan/cli-tools/issues"
13
+ },
14
+ "homepage": "https://git.dylan.lol/dylan/cli-tools/tree/main/simplefin-cli",
15
+ "keywords": [
16
+ "simplefin",
17
+ "finance",
18
+ "bank",
19
+ "cli",
20
+ "terminal"
21
+ ],
22
+ "bin": {
23
+ "simplefin": "./bin/simplefin.js"
24
+ },
25
+ "scripts": {
26
+ "test": "node test.js"
27
+ },
28
+ "dependencies": {
29
+ "axios": "^1.6.0",
30
+ "commander": "^11.0.0"
31
+ },
32
+ "devDependencies": {},
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
package/publish.sh ADDED
@@ -0,0 +1,53 @@
1
+ #!/bin/bash
2
+ # SimpleFIN CLI Publishing Script
3
+
4
+ set -e
5
+
6
+ echo "SimpleFIN CLI Publishing Script"
7
+ echo "================================"
8
+ echo ""
9
+
10
+ # Check if logged in
11
+ if ! npm whoami > /dev/null 2>&1; then
12
+ echo "You need to log in to npm first:"
13
+ echo " npm login"
14
+ echo ""
15
+ echo "Or create an account:"
16
+ echo " https://www.npmjs.com/signup"
17
+ echo ""
18
+ read -p "Press Enter after you've logged in..."
19
+ fi
20
+
21
+ # Verify login
22
+ echo "Logged in as: $(npm whoami)"
23
+ echo ""
24
+
25
+ # Go to simplefin-cli directory
26
+ cd "$(dirname "$0")"
27
+
28
+ # Check package name
29
+ echo "Package name: $(node -e 'console.log(require("./package.json").name)')"
30
+ echo "Version: $(node -e 'console.log(require("./package.json").version)')"
31
+ echo ""
32
+
33
+ # Confirm publish
34
+ read -p "Publish to npm? (y/n) " -n 1 -r
35
+ echo ""
36
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
37
+ echo "Aborted."
38
+ exit 1
39
+ fi
40
+
41
+ # Publish
42
+ npm publish
43
+
44
+ echo ""
45
+ echo "Published successfully!"
46
+ echo ""
47
+ echo "To install:"
48
+ echo " npm install -g simplefin-cli"
49
+ echo ""
50
+ echo "To use:"
51
+ echo " simplefin connect"
52
+ echo " simplefin accounts"
53
+ echo " simplefin transactions"
package/test.js ADDED
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const CLI = path.join(__dirname, 'bin', 'simplefin.js');
9
+ const configDir = path.join(os.homedir(), '.config', 'simplefin-cli');
10
+ const configFile = path.join(configDir, 'connection.json');
11
+
12
+ let passed = 0;
13
+ let failed = 0;
14
+
15
+ function run(args) {
16
+ return new Promise((resolve) => {
17
+ const proc = spawn('node', [CLI, ...args], { cwd: path.dirname(CLI) });
18
+ let stdout = '';
19
+ let stderr = '';
20
+
21
+ proc.stdout.on('data', d => stdout += d.toString());
22
+ proc.stderr.on('data', d => stderr += d.toString());
23
+
24
+ proc.on('close', code => resolve({ code, stdout, stderr }));
25
+ });
26
+ }
27
+
28
+ async function test(name, args, check) {
29
+ const result = await run(args);
30
+ try {
31
+ check(result);
32
+ console.log('✓ ' + name);
33
+ passed++;
34
+ } catch (e) {
35
+ console.log('✗ ' + name);
36
+ console.log(' Error: ' + e.message);
37
+ console.log(' stdout: ' + result.stdout.substring(0, 200));
38
+ console.log(' stderr: ' + result.stderr.substring(0, 200));
39
+ failed++;
40
+ }
41
+ }
42
+
43
+ async function testIntegration(name, args, check) {
44
+ const result = await run(args);
45
+ try {
46
+ check(result);
47
+ console.log('✓ ' + name);
48
+ passed++;
49
+ } catch (e) {
50
+ console.log('✗ ' + name);
51
+ console.log(' Error: ' + e.message);
52
+ console.log(' Output: ' + result.stdout.substring(0, 300));
53
+ failed++;
54
+ }
55
+ }
56
+
57
+ async function setup() {
58
+ console.log('╔════════════════════════════════════════════════════════════╗');
59
+ console.log('║ SimpleFIN CLI - Comprehensive Test Suite ║');
60
+ console.log('╚════════════════════════════════════════════════════════════╝\n');
61
+
62
+ // Clean state
63
+ try { fs.unlinkSync(configFile); } catch (e) {}
64
+
65
+ // ===== HELP TESTS =====
66
+ console.log('━━━ Help & Usage ━━━');
67
+
68
+ await test('Main help shows usage', ['--help'], function(r) {
69
+ if (r.stdout.indexOf('SimpleFIN CLI') === -1) throw new Error('Missing header');
70
+ if (r.stdout.indexOf('connect') === -1) throw new Error('Missing connect command');
71
+ if (r.stdout.indexOf('disconnect') === -1) throw new Error('Missing disconnect command');
72
+ if (r.stdout.indexOf('status') === -1) throw new Error('Missing status command');
73
+ if (r.stdout.indexOf('accounts') === -1) throw new Error('Missing accounts command');
74
+ if (r.stdout.indexOf('transactions') === -1) throw new Error('Missing transactions command');
75
+ if (r.stdout.indexOf('info') === -1) throw new Error('Missing info command');
76
+ });
77
+
78
+ await test('Connect help shows token option', ['connect', '--help'], function(r) {
79
+ if (r.stdout.indexOf('--token') === -1) throw new Error('Missing --token option');
80
+ });
81
+
82
+ await test('Transactions help shows all options', ['transactions', '--help'], function(r) {
83
+ if (r.stdout.indexOf('--days') === -1) throw new Error('Missing --days option');
84
+ if (r.stdout.indexOf('--start-date') === -1) throw new Error('Missing --start-date option');
85
+ if (r.stdout.indexOf('--end-date') === -1) throw new Error('Missing --end-date option');
86
+ if (r.stdout.indexOf('--account') === -1) throw new Error('Missing --account option');
87
+ if (r.stdout.indexOf('--pending') === -1) throw new Error('Missing --pending option');
88
+ });
89
+
90
+ await test('Accounts help shows balances-only', ['accounts', '--help'], function(r) {
91
+ if (r.stdout.indexOf('--balances-only') === -1) throw new Error('Missing --balances-only option');
92
+ });
93
+
94
+ await test('Info help shows usage', ['info', '--help'], function(r) {
95
+ if (r.stdout.indexOf('info') === -1) throw new Error('Missing info help');
96
+ });
97
+
98
+ // ===== ERROR HANDLING TESTS =====
99
+ console.log('\n━━━ Error Handling ━━━');
100
+
101
+ await test('Status exits 1 when not connected', ['status'], function(r) {
102
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
103
+ if (r.stdout.toLowerCase().indexOf('not connected') === -1) throw new Error('Missing error msg');
104
+ });
105
+
106
+ await test('Accounts exits 1 when not connected', ['accounts'], function(r) {
107
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
108
+ });
109
+
110
+ await test('Transactions exits 1 when not connected', ['transactions'], function(r) {
111
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
112
+ });
113
+
114
+ await test('Info exits 1 when not connected', ['info'], function(r) {
115
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
116
+ });
117
+
118
+ await test('Connect without token shows error', ['connect'], function(r) {
119
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
120
+ if (r.stdout.toLowerCase().indexOf('token') === -1) throw new Error('Missing token error');
121
+ });
122
+
123
+ await test('Disconnect succeeds even when not connected', ['disconnect'], function(r) {
124
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
125
+ });
126
+
127
+ // ===== ARGUMENT PARSING TESTS =====
128
+ console.log('\n━━━ Argument Parsing ━━━');
129
+
130
+ var argTests = [
131
+ ['transactions', '--days', '7'],
132
+ ['transactions', '--start-date', '2026-01-01'],
133
+ ['transactions', '--end-date', '2026-01-10'],
134
+ ['transactions', '--account', 'ACT-123'],
135
+ ['transactions', '--pending'],
136
+ ['transactions', '--days', '30', '--json'],
137
+ ['accounts', '--balances-only'],
138
+ ['accounts', '--json'],
139
+ ];
140
+
141
+ for (var i = 0; i < argTests.length; i++) {
142
+ var args = argTests[i];
143
+ await test('Parses: ' + args.join(' '), args, function(r) {
144
+ if (r.code !== 1) throw new Error('Should exit 1 (not connected), got ' + r.code);
145
+ });
146
+ }
147
+
148
+ // ===== EDGE CASES =====
149
+ console.log('\n━━━ Edge Cases ━━━');
150
+
151
+ await test('Unknown command shows error', ['unknown-command'], function(r) {
152
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
153
+ });
154
+
155
+ await test('Missing required argument shows usage', ['transactions', '--start-date'], function(r) {
156
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
157
+ });
158
+
159
+ // ===== INTEGRATION TESTS =====
160
+ console.log('\n━━━ Integration Tests ━━━');
161
+
162
+ var realToken = process.env.SIMPLEFIN_TOKEN || 'aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3JnL3NpbXBsZWZpbi9jbGFpbS85RUE5MjM5NzlDN0ZCMDk3REFDQTU0NDJDOTcwNUZGNDRDODkyNDk1NkQzRDQxOTkyMUVDNDNCNEFBNTE3QTEwMzM1MUMxNDhGRDRFQzdFMTYxRUZDNTQwMUE5QzVENjI0MEZFNEZDRkEyN0VFMjM1RUIwRDRCRjI4NDYyODNBOQ==';
163
+
164
+ try {
165
+ await testIntegration('Connect with real token', ['connect', '--token', realToken], function(r) {
166
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
167
+ if (r.stdout.toLowerCase().indexOf('connected') === -1) throw new Error('Missing success message');
168
+ });
169
+
170
+ await testIntegration('Status shows connected', ['status'], function(r) {
171
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
172
+ if (r.stdout.toLowerCase().indexOf('connected') === -1) throw new Error('Missing connected status');
173
+ });
174
+
175
+ await testIntegration('Status --json outputs valid JSON', ['status', '--json'], function(r) {
176
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
177
+ try {
178
+ JSON.parse(r.stdout);
179
+ } catch (e) {
180
+ throw new Error('Invalid JSON: ' + e.message);
181
+ }
182
+ });
183
+
184
+ await testIntegration('Accounts lists accounts', ['accounts'], function(r) {
185
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
186
+ if (r.stdout.toLowerCase().indexOf('accounts') === -1) throw new Error('Missing accounts header');
187
+ });
188
+
189
+ await testIntegration('Accounts --json outputs valid JSON', ['accounts', '--json'], function(r) {
190
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
191
+ try {
192
+ var data = JSON.parse(r.stdout);
193
+ if (!data.accounts) throw new Error('Missing accounts array');
194
+ } catch (e) {
195
+ throw new Error('Invalid JSON: ' + e.message);
196
+ }
197
+ });
198
+
199
+ await testIntegration('Transactions shows transactions', ['transactions', '--days', '7'], function(r) {
200
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
201
+ if (r.stdout.toLowerCase().indexOf('transactions') === -1) throw new Error('Missing transactions header');
202
+ });
203
+
204
+ await testIntegration('Transactions --json outputs valid JSON with count', ['transactions', '--days', '7', '--json'], function(r) {
205
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
206
+ try {
207
+ var data = JSON.parse(r.stdout);
208
+ if (typeof data.count !== 'number') throw new Error('Missing count field');
209
+ if (!Array.isArray(data.transactions)) throw new Error('Missing transactions array');
210
+ } catch (e) {
211
+ throw new Error('Invalid JSON: ' + e.message);
212
+ }
213
+ });
214
+
215
+ await testIntegration('Info shows server info', ['info'], function(r) {
216
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
217
+ if (r.stdout.toLowerCase().indexOf('server') === -1) throw new Error('Missing server info');
218
+ });
219
+
220
+ await testIntegration('Disconnect succeeds', ['disconnect'], function(r) {
221
+ if (r.code !== 0) throw new Error('Expected exit 0, got ' + r.code);
222
+ });
223
+
224
+ await testIntegration('Status shows disconnected after disconnect', ['status'], function(r) {
225
+ if (r.code !== 1) throw new Error('Expected exit 1, got ' + r.code);
226
+ if (r.stdout.toLowerCase().indexOf('not connected') === -1) throw new Error('Missing not connected');
227
+ });
228
+
229
+ } catch (e) {
230
+ console.log('⚠ Integration tests skipped: ' + e.message);
231
+ }
232
+
233
+ // ===== SUMMARY =====
234
+ console.log('\n╔════════════════════════════════════════════════════════════╗');
235
+ console.log('║ Results: ' + passed + ' passed, ' + failed + ' failed ║');
236
+ console.log('╚════════════════════════════════════════════════════════════╝');
237
+
238
+ process.exit(failed > 0 ? 1 : 0);
239
+ }
240
+
241
+ setup();