sheetlink 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/package.json +41 -0
- package/src/adapters/csv.js +45 -0
- package/src/adapters/json.js +10 -0
- package/src/adapters/postgres.js +98 -0
- package/src/adapters/sqlite.js +95 -0
- package/src/api.js +60 -0
- package/src/commands/auth.js +165 -0
- package/src/commands/config.js +70 -0
- package/src/commands/items.js +27 -0
- package/src/commands/sync.js +88 -0
- package/src/config.js +78 -0
- package/src/index.js +82 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sheetlink",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for SheetLink — sync your bank transactions to any destination",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sheetlink": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^12.1.0",
|
|
20
|
+
"open": "^10.1.0",
|
|
21
|
+
"pg": "^8.13.3",
|
|
22
|
+
"better-sqlite3": "^11.9.1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"sheetlink",
|
|
29
|
+
"plaid",
|
|
30
|
+
"finance",
|
|
31
|
+
"banking",
|
|
32
|
+
"transactions",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"author": "Rudy Martin Del Campo <rudy@sheetlink.app>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/sheetlink/sheetlink-client"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csv.js - CSV snapshot adapter
|
|
3
|
+
*
|
|
4
|
+
* Writes a flat snapshot of transactions to a CSV file.
|
|
5
|
+
* Each run OVERWRITES the file — no append, no dedup.
|
|
6
|
+
* Users who want history should use Postgres or SQLite.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
|
|
11
|
+
const HEADERS = [
|
|
12
|
+
'date',
|
|
13
|
+
'name',
|
|
14
|
+
'amount',
|
|
15
|
+
'category',
|
|
16
|
+
'account_id',
|
|
17
|
+
'account_name',
|
|
18
|
+
'account_mask',
|
|
19
|
+
'institution_name',
|
|
20
|
+
'pending',
|
|
21
|
+
'payment_channel',
|
|
22
|
+
'merchant_name',
|
|
23
|
+
'primary_category',
|
|
24
|
+
'detailed_category',
|
|
25
|
+
'transaction_id',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function escape(val) {
|
|
29
|
+
if (val === null || val === undefined) return '';
|
|
30
|
+
const s = String(val);
|
|
31
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
32
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatRow(txn) {
|
|
38
|
+
return HEADERS.map(h => escape(txn[h])).join(',');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeCsv(transactions, filePath = './sheetlink-transactions.csv') {
|
|
42
|
+
const lines = [HEADERS.join(','), ...transactions.map(formatRow)];
|
|
43
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf8');
|
|
44
|
+
console.error(`Wrote ${transactions.length} transactions to ${filePath}`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* json.js - JSON stdout adapter
|
|
3
|
+
*
|
|
4
|
+
* Writes full sync result to stdout as JSON.
|
|
5
|
+
* Pipeable: sheetlink sync | jq '.transactions[] | select(.amount > 100)'
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function writeJson(result) {
|
|
9
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
10
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postgres.js - Postgres upsert adapter (MAX tier only)
|
|
3
|
+
*
|
|
4
|
+
* Creates sheetlink_transactions and sheetlink_accounts tables if they don't exist.
|
|
5
|
+
* Upserts on primary key — safe to run repeatedly with no duplicates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CREATE_TRANSACTIONS = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
10
|
+
transaction_id TEXT PRIMARY KEY,
|
|
11
|
+
date DATE NOT NULL,
|
|
12
|
+
name TEXT,
|
|
13
|
+
amount DECIMAL(10,2),
|
|
14
|
+
category TEXT,
|
|
15
|
+
account_id TEXT,
|
|
16
|
+
account_name TEXT,
|
|
17
|
+
account_mask TEXT,
|
|
18
|
+
institution_name TEXT,
|
|
19
|
+
pending BOOLEAN,
|
|
20
|
+
payment_channel TEXT,
|
|
21
|
+
merchant_name TEXT,
|
|
22
|
+
primary_category TEXT,
|
|
23
|
+
detailed_category TEXT,
|
|
24
|
+
synced_at TIMESTAMP DEFAULT NOW()
|
|
25
|
+
)`;
|
|
26
|
+
|
|
27
|
+
const CREATE_ACCOUNTS = `
|
|
28
|
+
CREATE TABLE IF NOT EXISTS sheetlink_accounts (
|
|
29
|
+
account_id TEXT PRIMARY KEY,
|
|
30
|
+
name TEXT,
|
|
31
|
+
mask TEXT,
|
|
32
|
+
type TEXT,
|
|
33
|
+
subtype TEXT,
|
|
34
|
+
balance_current DECIMAL(10,2),
|
|
35
|
+
balance_available DECIMAL(10,2),
|
|
36
|
+
institution_name TEXT,
|
|
37
|
+
last_synced_at TIMESTAMP DEFAULT NOW()
|
|
38
|
+
)`;
|
|
39
|
+
|
|
40
|
+
const UPSERT_TRANSACTION = `
|
|
41
|
+
INSERT INTO sheetlink_transactions
|
|
42
|
+
(transaction_id, date, name, amount, category, account_id, account_name, account_mask,
|
|
43
|
+
institution_name, pending, payment_channel, merchant_name, primary_category, detailed_category, synced_at)
|
|
44
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
|
45
|
+
ON CONFLICT (transaction_id) DO UPDATE SET
|
|
46
|
+
date = EXCLUDED.date,
|
|
47
|
+
name = EXCLUDED.name,
|
|
48
|
+
amount = EXCLUDED.amount,
|
|
49
|
+
category = EXCLUDED.category,
|
|
50
|
+
pending = EXCLUDED.pending,
|
|
51
|
+
synced_at = NOW()`;
|
|
52
|
+
|
|
53
|
+
const UPSERT_ACCOUNT = `
|
|
54
|
+
INSERT INTO sheetlink_accounts
|
|
55
|
+
(account_id, name, mask, type, subtype, balance_current, balance_available, institution_name, last_synced_at)
|
|
56
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
|
|
57
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
58
|
+
name = EXCLUDED.name,
|
|
59
|
+
balance_current = EXCLUDED.balance_current,
|
|
60
|
+
balance_available = EXCLUDED.balance_available,
|
|
61
|
+
last_synced_at = NOW()`;
|
|
62
|
+
|
|
63
|
+
export async function writePostgres(transactions, accounts, connectionString) {
|
|
64
|
+
// Dynamic import so users without pg installed don't hit an error on other commands
|
|
65
|
+
const { default: pg } = await import('pg').then(m => ({ default: m.default || m }));
|
|
66
|
+
const { Client } = pg;
|
|
67
|
+
|
|
68
|
+
const client = new Client({ connectionString });
|
|
69
|
+
await client.connect();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await client.query(CREATE_TRANSACTIONS);
|
|
73
|
+
await client.query(CREATE_ACCOUNTS);
|
|
74
|
+
|
|
75
|
+
for (const tx of transactions) {
|
|
76
|
+
await client.query(UPSERT_TRANSACTION, [
|
|
77
|
+
tx.transaction_id, tx.date, tx.name, tx.amount, tx.category,
|
|
78
|
+
tx.account_id, tx.account_name, tx.account_mask,
|
|
79
|
+
tx.institution_name || tx.source_institution,
|
|
80
|
+
tx.pending, tx.payment_channel, tx.merchant_name,
|
|
81
|
+
tx.primary_category, tx.detailed_category,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const acc of accounts) {
|
|
86
|
+
await client.query(UPSERT_ACCOUNT, [
|
|
87
|
+
acc.account_id, acc.name, acc.mask, acc.type, acc.subtype,
|
|
88
|
+
acc.balances?.current ?? null,
|
|
89
|
+
acc.balances?.available ?? null,
|
|
90
|
+
acc.institution || acc.institution_name || null,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.error(`Synced ${transactions.length} transactions and ${accounts.length} accounts to Postgres`);
|
|
95
|
+
} finally {
|
|
96
|
+
await client.end();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite.js - SQLite upsert adapter (MAX tier only)
|
|
3
|
+
*
|
|
4
|
+
* Same schema as Postgres. Upserts on primary key — safe to run repeatedly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CREATE_TRANSACTIONS = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
9
|
+
transaction_id TEXT PRIMARY KEY,
|
|
10
|
+
date TEXT NOT NULL,
|
|
11
|
+
name TEXT,
|
|
12
|
+
amount REAL,
|
|
13
|
+
category TEXT,
|
|
14
|
+
account_id TEXT,
|
|
15
|
+
account_name TEXT,
|
|
16
|
+
account_mask TEXT,
|
|
17
|
+
institution_name TEXT,
|
|
18
|
+
pending INTEGER,
|
|
19
|
+
payment_channel TEXT,
|
|
20
|
+
merchant_name TEXT,
|
|
21
|
+
primary_category TEXT,
|
|
22
|
+
detailed_category TEXT,
|
|
23
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
24
|
+
)`;
|
|
25
|
+
|
|
26
|
+
const CREATE_ACCOUNTS = `
|
|
27
|
+
CREATE TABLE IF NOT EXISTS sheetlink_accounts (
|
|
28
|
+
account_id TEXT PRIMARY KEY,
|
|
29
|
+
name TEXT,
|
|
30
|
+
mask TEXT,
|
|
31
|
+
type TEXT,
|
|
32
|
+
subtype TEXT,
|
|
33
|
+
balance_current REAL,
|
|
34
|
+
balance_available REAL,
|
|
35
|
+
institution_name TEXT,
|
|
36
|
+
last_synced_at TEXT DEFAULT (datetime('now'))
|
|
37
|
+
)`;
|
|
38
|
+
|
|
39
|
+
const UPSERT_TRANSACTION = `
|
|
40
|
+
INSERT INTO sheetlink_transactions
|
|
41
|
+
(transaction_id, date, name, amount, category, account_id, account_name, account_mask,
|
|
42
|
+
institution_name, pending, payment_channel, merchant_name, primary_category, detailed_category, synced_at)
|
|
43
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
44
|
+
ON CONFLICT(transaction_id) DO UPDATE SET
|
|
45
|
+
date=excluded.date, name=excluded.name, amount=excluded.amount,
|
|
46
|
+
category=excluded.category, pending=excluded.pending, synced_at=datetime('now')`;
|
|
47
|
+
|
|
48
|
+
const UPSERT_ACCOUNT = `
|
|
49
|
+
INSERT INTO sheetlink_accounts
|
|
50
|
+
(account_id, name, mask, type, subtype, balance_current, balance_available, institution_name, last_synced_at)
|
|
51
|
+
VALUES (?,?,?,?,?,?,?,?,datetime('now'))
|
|
52
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
53
|
+
name=excluded.name, balance_current=excluded.balance_current,
|
|
54
|
+
balance_available=excluded.balance_available, last_synced_at=datetime('now')`;
|
|
55
|
+
|
|
56
|
+
export async function writeSQLite(transactions, accounts, dbPath) {
|
|
57
|
+
// Dynamic import so users without better-sqlite3 don't hit an error on other commands
|
|
58
|
+
const Database = (await import('better-sqlite3')).default;
|
|
59
|
+
|
|
60
|
+
const db = new Database(dbPath);
|
|
61
|
+
db.exec(CREATE_TRANSACTIONS);
|
|
62
|
+
db.exec(CREATE_ACCOUNTS);
|
|
63
|
+
|
|
64
|
+
const insertTx = db.prepare(UPSERT_TRANSACTION);
|
|
65
|
+
const insertAcc = db.prepare(UPSERT_ACCOUNT);
|
|
66
|
+
|
|
67
|
+
const txBatch = db.transaction((txns) => {
|
|
68
|
+
for (const tx of txns) {
|
|
69
|
+
insertTx.run(
|
|
70
|
+
tx.transaction_id, tx.date, tx.name, tx.amount, tx.category,
|
|
71
|
+
tx.account_id, tx.account_name, tx.account_mask,
|
|
72
|
+
tx.institution_name || tx.source_institution,
|
|
73
|
+
tx.pending ? 1 : 0, tx.payment_channel, tx.merchant_name,
|
|
74
|
+
tx.primary_category, tx.detailed_category,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const accBatch = db.transaction((accs) => {
|
|
80
|
+
for (const acc of accs) {
|
|
81
|
+
insertAcc.run(
|
|
82
|
+
acc.account_id, acc.name, acc.mask, acc.type, acc.subtype,
|
|
83
|
+
acc.balances?.current ?? null,
|
|
84
|
+
acc.balances?.available ?? null,
|
|
85
|
+
acc.institution || acc.institution_name || null,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
txBatch(transactions);
|
|
91
|
+
accBatch(accounts);
|
|
92
|
+
db.close();
|
|
93
|
+
|
|
94
|
+
console.error(`Synced ${transactions.length} transactions and ${accounts.length} accounts to ${dbPath}`);
|
|
95
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api.js - SheetLink API client
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around fetch for the SheetLink backend.
|
|
5
|
+
* All endpoints require Authorization: Bearer <token>.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getApiUrl, getAuthHeader } from './config.js';
|
|
9
|
+
|
|
10
|
+
async function request(method, path, body = null) {
|
|
11
|
+
const auth = getAuthHeader();
|
|
12
|
+
if (!auth) {
|
|
13
|
+
console.error('Not authenticated. Run `sheetlink auth` to set up credentials.');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const url = `${getApiUrl()}${path}`;
|
|
18
|
+
const opts = {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'Authorization': auth,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
if (body) opts.body = JSON.stringify(body);
|
|
26
|
+
|
|
27
|
+
const res = await fetch(url, opts);
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
let detail = res.statusText;
|
|
31
|
+
try {
|
|
32
|
+
const err = await res.json();
|
|
33
|
+
detail = err.detail || JSON.stringify(err);
|
|
34
|
+
} catch {}
|
|
35
|
+
|
|
36
|
+
if (res.status === 401) {
|
|
37
|
+
console.error('Authentication failed. Run `sheetlink auth` to re-authenticate.');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 403) {
|
|
41
|
+
console.error(`Access denied: ${detail}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`API error ${res.status}: ${detail}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listItems() {
|
|
51
|
+
return request('GET', '/api/items');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function syncItem(itemId) {
|
|
55
|
+
return request('POST', '/api/sync', { item_id: itemId });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getTierStatus() {
|
|
59
|
+
return request('GET', '/tier/status');
|
|
60
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.js - `sheetlink auth`
|
|
3
|
+
*
|
|
4
|
+
* Two paths:
|
|
5
|
+
* API key (MAX): `sheetlink auth --api-key sl_...`
|
|
6
|
+
* Stores key in ~/.sheetlink/config.json
|
|
7
|
+
*
|
|
8
|
+
* OAuth (PRO): `sheetlink auth`
|
|
9
|
+
* Opens Google OAuth in browser, exchanges for JWT,
|
|
10
|
+
* stores JWT in config file.
|
|
11
|
+
*
|
|
12
|
+
* Note: PRO JWT auth is interactive only — tokens expire ~1hr.
|
|
13
|
+
* For unattended automation, use MAX tier + API key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from 'http';
|
|
17
|
+
import { randomBytes } from 'crypto';
|
|
18
|
+
import { writeConfig, getApiUrl } from '../config.js';
|
|
19
|
+
import { getTierStatus } from '../api.js';
|
|
20
|
+
|
|
21
|
+
const GOOGLE_CLIENT_ID = '967710910027-qq2tuel7vsi2i06h4h096hbvok8kfmhk.apps.googleusercontent.com';
|
|
22
|
+
const REDIRECT_PORT = 9876;
|
|
23
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
|
|
24
|
+
|
|
25
|
+
export async function cmdAuth(options) {
|
|
26
|
+
// --- API key path ---
|
|
27
|
+
if (options.apiKey) {
|
|
28
|
+
if (!options.apiKey.startsWith('sl_')) {
|
|
29
|
+
console.error('Invalid API key format. Keys should start with sl_');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Warn about shell history exposure
|
|
34
|
+
console.error('');
|
|
35
|
+
console.error('Security note: API keys passed via --api-key may appear in your shell history.');
|
|
36
|
+
console.error('Consider setting SHEETLINK_API_KEY as an environment variable instead:');
|
|
37
|
+
console.error(' export SHEETLINK_API_KEY=sl_...');
|
|
38
|
+
console.error('');
|
|
39
|
+
|
|
40
|
+
writeConfig({ api_key: options.apiKey, jwt: null });
|
|
41
|
+
console.log('API key saved to ~/.sheetlink/config.json');
|
|
42
|
+
console.log('');
|
|
43
|
+
|
|
44
|
+
// Verify it works
|
|
45
|
+
try {
|
|
46
|
+
const status = await getTierStatus();
|
|
47
|
+
if (status.subscription_tier !== 'max') {
|
|
48
|
+
console.error(`Warning: This key belongs to a ${status.subscription_tier} account. MAX tier is required for API key auth.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
console.log(`Authenticated as ${status.email} (${status.subscription_tier} tier)`);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error(`Could not verify key: ${e.message}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- OAuth / JWT path (PRO interactive) ---
|
|
60
|
+
console.log('Opening Google sign-in in your browser...');
|
|
61
|
+
console.log('');
|
|
62
|
+
|
|
63
|
+
const idToken = await googleOAuthFlow();
|
|
64
|
+
const jwt = await exchangeGoogleToken(idToken);
|
|
65
|
+
|
|
66
|
+
writeConfig({ jwt, api_key: null });
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log('Authenticated. JWT saved to ~/.sheetlink/config.json');
|
|
69
|
+
console.log('Note: JWT expires in ~1 hour. Re-run `sheetlink auth` when needed.');
|
|
70
|
+
console.log('For unattended automation, upgrade to MAX and use `sheetlink auth --api-key sl_...`');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function googleOAuthFlow() {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const state = randomBytes(16).toString('hex');
|
|
76
|
+
const nonce = randomBytes(16).toString('hex');
|
|
77
|
+
|
|
78
|
+
const params = new URLSearchParams({
|
|
79
|
+
client_id: GOOGLE_CLIENT_ID,
|
|
80
|
+
redirect_uri: REDIRECT_URI,
|
|
81
|
+
response_type: 'id_token',
|
|
82
|
+
scope: 'openid email profile',
|
|
83
|
+
state,
|
|
84
|
+
nonce,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
88
|
+
|
|
89
|
+
// Start local server to catch the redirect
|
|
90
|
+
const server = http.createServer((req, res) => {
|
|
91
|
+
const url = new URL(req.url, `http://localhost:${REDIRECT_PORT}`);
|
|
92
|
+
|
|
93
|
+
// Google returns id_token in the fragment (#) which is client-side only.
|
|
94
|
+
// We serve a tiny page that POSTs it to our server.
|
|
95
|
+
if (url.pathname === '/callback') {
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
97
|
+
res.end(`<!DOCTYPE html>
|
|
98
|
+
<html>
|
|
99
|
+
<body>
|
|
100
|
+
<script>
|
|
101
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
102
|
+
fetch('/token', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {'Content-Type': 'application/json'},
|
|
105
|
+
body: JSON.stringify({ id_token: params.get('id_token'), state: params.get('state') })
|
|
106
|
+
}).then(() => {
|
|
107
|
+
document.body.innerHTML = '<p>Authenticated! You can close this tab.</p>';
|
|
108
|
+
});
|
|
109
|
+
</script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === '/token' && req.method === 'POST') {
|
|
116
|
+
let body = '';
|
|
117
|
+
req.on('data', d => body += d);
|
|
118
|
+
req.on('end', () => {
|
|
119
|
+
res.writeHead(200);
|
|
120
|
+
res.end('ok');
|
|
121
|
+
server.close();
|
|
122
|
+
try {
|
|
123
|
+
const { id_token, state: returnedState } = JSON.parse(body);
|
|
124
|
+
if (returnedState !== state) return reject(new Error('State mismatch — possible CSRF'));
|
|
125
|
+
resolve(id_token);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
reject(e);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
res.writeHead(404);
|
|
134
|
+
res.end();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.listen(REDIRECT_PORT, async () => {
|
|
138
|
+
try {
|
|
139
|
+
const { default: open } = await import('open');
|
|
140
|
+
await open(authUrl);
|
|
141
|
+
} catch {
|
|
142
|
+
console.log(`Open this URL in your browser:\n${authUrl}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
server.on('error', reject);
|
|
147
|
+
setTimeout(() => { server.close(); reject(new Error('OAuth timeout (2 minutes)')); }, 120_000);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function exchangeGoogleToken(idToken) {
|
|
152
|
+
const res = await fetch(`${getApiUrl()}/auth/login`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ id_token: idToken }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const err = await res.json().catch(() => ({}));
|
|
160
|
+
throw new Error(`Login failed: ${err.detail || res.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await res.json();
|
|
164
|
+
return data.token;
|
|
165
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - `sheetlink config`
|
|
3
|
+
*
|
|
4
|
+
* Show or update CLI configuration.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* sheetlink config # Show current config
|
|
8
|
+
* sheetlink config --set output=json # Set default output
|
|
9
|
+
* sheetlink config --set api_url=https://api.sheetlink.app
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { readConfig, writeConfig } from '../config.js';
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILE = path.join(os.homedir(), '.sheetlink', 'config.json');
|
|
17
|
+
|
|
18
|
+
const SETTABLE_KEYS = ['default_output', 'api_url'];
|
|
19
|
+
|
|
20
|
+
export function cmdConfig(options) {
|
|
21
|
+
if (options.set) {
|
|
22
|
+
const eqIdx = options.set.indexOf('=');
|
|
23
|
+
if (eqIdx === -1) {
|
|
24
|
+
console.error('Usage: sheetlink config --set key=value');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const key = options.set.slice(0, eqIdx);
|
|
28
|
+
const value = options.set.slice(eqIdx + 1);
|
|
29
|
+
|
|
30
|
+
if (!SETTABLE_KEYS.includes(key)) {
|
|
31
|
+
console.error(`Unknown config key: ${key}`);
|
|
32
|
+
console.error(`Settable keys: ${SETTABLE_KEYS.join(', ')}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
writeConfig({ [key]: value });
|
|
37
|
+
console.log(`Set ${key}=${value}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Show current config
|
|
42
|
+
const cfg = readConfig();
|
|
43
|
+
|
|
44
|
+
console.log(`\nConfig file: ${CONFIG_FILE}\n`);
|
|
45
|
+
|
|
46
|
+
if (!cfg || Object.keys(cfg).length === 0) {
|
|
47
|
+
console.log('No config set. Run `sheetlink auth` to get started.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const display = {
|
|
52
|
+
...cfg,
|
|
53
|
+
api_key: cfg.api_key ? `${cfg.api_key.slice(0, 8)}...` : undefined,
|
|
54
|
+
jwt: cfg.jwt ? '<set>' : undefined,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const [k, v] of Object.entries(display)) {
|
|
58
|
+
if (v !== undefined) console.log(` ${k}: ${v}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('');
|
|
62
|
+
|
|
63
|
+
// Env var overrides
|
|
64
|
+
if (process.env.SHEETLINK_API_KEY) {
|
|
65
|
+
console.log(' SHEETLINK_API_KEY: <set via env var> (overrides config file)');
|
|
66
|
+
}
|
|
67
|
+
if (process.env.SHEETLINK_OUTPUT) {
|
|
68
|
+
console.log(` SHEETLINK_OUTPUT: ${process.env.SHEETLINK_OUTPUT} (overrides config file)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* items.js - `sheetlink items`
|
|
3
|
+
*
|
|
4
|
+
* Lists all connected bank accounts for the authenticated user.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { listItems } from '../api.js';
|
|
8
|
+
|
|
9
|
+
export async function cmdItems() {
|
|
10
|
+
const { items } = await listItems();
|
|
11
|
+
|
|
12
|
+
if (!items || items.length === 0) {
|
|
13
|
+
console.log('No connected banks. Connect one at https://sheetlink.app/dashboard');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`\nConnected banks (${items.length}):\n`);
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
const lastSync = item.last_synced_at
|
|
20
|
+
? new Date(item.last_synced_at).toLocaleString()
|
|
21
|
+
: 'never';
|
|
22
|
+
console.log(` ${item.institution_name || 'Unknown'}`);
|
|
23
|
+
console.log(` item_id: ${item.item_id}`);
|
|
24
|
+
console.log(` last synced: ${lastSync}`);
|
|
25
|
+
console.log('');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync.js - `sheetlink sync`
|
|
3
|
+
*
|
|
4
|
+
* Fetches transactions from SheetLink API and routes to output adapter.
|
|
5
|
+
*
|
|
6
|
+
* Output modes:
|
|
7
|
+
* json (default) - JSON to stdout, pipeable
|
|
8
|
+
* csv [--file path] - Snapshot CSV, overwrites each run (PRO+)
|
|
9
|
+
* postgres://... - Upsert to Postgres (MAX only)
|
|
10
|
+
* sqlite:///path/to/db - Upsert to SQLite (MAX only)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { listItems, syncItem } from '../api.js';
|
|
14
|
+
import { getDefaultOutput } from '../config.js';
|
|
15
|
+
import { writeJson } from '../adapters/json.js';
|
|
16
|
+
import { writeCsv } from '../adapters/csv.js';
|
|
17
|
+
import { writePostgres } from '../adapters/postgres.js';
|
|
18
|
+
import { writeSQLite } from '../adapters/sqlite.js';
|
|
19
|
+
|
|
20
|
+
export async function cmdSync(options) {
|
|
21
|
+
const output = options.output || getDefaultOutput();
|
|
22
|
+
const itemId = options.item || null;
|
|
23
|
+
|
|
24
|
+
// Collect items to sync
|
|
25
|
+
let itemIds;
|
|
26
|
+
if (itemId) {
|
|
27
|
+
itemIds = [itemId];
|
|
28
|
+
} else {
|
|
29
|
+
const { items } = await listItems();
|
|
30
|
+
if (!items || items.length === 0) {
|
|
31
|
+
console.error('No connected banks found. Connect a bank at https://sheetlink.app/dashboard');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
itemIds = items.map(i => i.item_id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Sync each item and collect results
|
|
38
|
+
const allTransactions = [];
|
|
39
|
+
const allAccounts = [];
|
|
40
|
+
const results = [];
|
|
41
|
+
|
|
42
|
+
for (const id of itemIds) {
|
|
43
|
+
process.stderr.write(`Syncing ${id}...`);
|
|
44
|
+
try {
|
|
45
|
+
const result = await syncItem(id);
|
|
46
|
+
allTransactions.push(...(result.transactions || []));
|
|
47
|
+
allAccounts.push(...(result.accounts || []));
|
|
48
|
+
results.push({ item_id: id, ...result });
|
|
49
|
+
process.stderr.write(` ${result.transactions?.length ?? 0} transactions\n`);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
process.stderr.write(` error: ${e.message}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (allTransactions.length === 0 && allAccounts.length === 0) {
|
|
56
|
+
console.error('No data returned from sync.');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const synced_at = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
// Route to output adapter
|
|
63
|
+
if (output === 'json') {
|
|
64
|
+
writeJson({ synced_at, items: results });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (output === 'csv') {
|
|
69
|
+
writeCsv(allTransactions, options.file);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (output.startsWith('postgres://') || output.startsWith('postgresql://')) {
|
|
74
|
+
await writePostgres(allTransactions, allAccounts, output);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (output.startsWith('sqlite://')) {
|
|
79
|
+
// sqlite:///absolute/path or sqlite://relative/path
|
|
80
|
+
const dbPath = output.replace(/^sqlite:\/\/\/?/, '') || './sheetlink.db';
|
|
81
|
+
writeSQLite(allTransactions, allAccounts, dbPath);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.error(`Unknown output: ${output}`);
|
|
86
|
+
console.error('Valid options: json, csv, postgres://..., sqlite:///path/to/db');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - Read/write ~/.sheetlink/config.json
|
|
3
|
+
*
|
|
4
|
+
* Config file stores:
|
|
5
|
+
* api_key - SheetLink API key (sl_...) for MAX tier unattended auth
|
|
6
|
+
* jwt - JWT token for PRO tier interactive auth
|
|
7
|
+
* default_output - Default output mode (json, csv, postgres://..., sqlite://...)
|
|
8
|
+
* api_url - Backend URL (default: https://api.sheetlink.app)
|
|
9
|
+
*
|
|
10
|
+
* Priority for API key: SHEETLINK_API_KEY env var > config file
|
|
11
|
+
* Priority for JWT: config file only (set by `sheetlink auth`)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import os from 'os';
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), '.sheetlink');
|
|
19
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
20
|
+
const DEFAULT_API_URL = 'https://api.sheetlink.app';
|
|
21
|
+
|
|
22
|
+
export function readConfig() {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
25
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeConfig(updates) {
|
|
32
|
+
const current = readConfig();
|
|
33
|
+
const next = { ...current, ...updates };
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
36
|
+
fs.mkdirSync(CONFIG_DIR, { mode: 0o700 });
|
|
37
|
+
} else {
|
|
38
|
+
// Enforce correct permissions even if dir already exists
|
|
39
|
+
fs.chmodSync(CONFIG_DIR, 0o700);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
43
|
+
// Enforce file permissions after write (some systems ignore mode on writeFileSync)
|
|
44
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getApiKey() {
|
|
49
|
+
// Env var takes precedence
|
|
50
|
+
if (process.env.SHEETLINK_API_KEY) return process.env.SHEETLINK_API_KEY;
|
|
51
|
+
return readConfig().api_key || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getJwt() {
|
|
55
|
+
return readConfig().jwt || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getApiUrl() {
|
|
59
|
+
return process.env.SHEETLINK_API_URL || readConfig().api_url || DEFAULT_API_URL;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getDefaultOutput() {
|
|
63
|
+
return process.env.SHEETLINK_OUTPUT || readConfig().default_output || 'json';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the best available auth header value, or null if not configured.
|
|
68
|
+
* API key takes priority over JWT (API key = MAX unattended, JWT = PRO interactive).
|
|
69
|
+
*/
|
|
70
|
+
export function getAuthHeader() {
|
|
71
|
+
const apiKey = getApiKey();
|
|
72
|
+
if (apiKey) return `Bearer ${apiKey}`;
|
|
73
|
+
|
|
74
|
+
const jwt = getJwt();
|
|
75
|
+
if (jwt) return `Bearer ${jwt}`;
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SheetLink CLI
|
|
5
|
+
*
|
|
6
|
+
* Sync your bank transactions to any destination — JSON, CSV, Postgres, SQLite.
|
|
7
|
+
*
|
|
8
|
+
* PRO tier: interactive auth (JWT), manual runs
|
|
9
|
+
* MAX tier: API key auth, unattended/cron automation
|
|
10
|
+
*
|
|
11
|
+
* https://sheetlink.app
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { program } from 'commander';
|
|
15
|
+
import { createRequire } from 'module';
|
|
16
|
+
import { cmdAuth } from './commands/auth.js';
|
|
17
|
+
import { cmdSync } from './commands/sync.js';
|
|
18
|
+
import { cmdItems } from './commands/items.js';
|
|
19
|
+
import { cmdConfig } from './commands/config.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const pkg = require('../package.json');
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.name('sheetlink')
|
|
26
|
+
.description('Sync bank transactions from SheetLink to any destination')
|
|
27
|
+
.version(pkg.version);
|
|
28
|
+
|
|
29
|
+
// ── auth ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('auth')
|
|
33
|
+
.description('Authenticate with SheetLink (OAuth for PRO, API key for MAX)')
|
|
34
|
+
.option('--api-key <key>', 'Set a MAX tier API key (sl_...) for unattended automation')
|
|
35
|
+
.action(cmdAuth);
|
|
36
|
+
|
|
37
|
+
// ── sync ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('sync')
|
|
41
|
+
.description('Sync bank transactions and output to chosen destination')
|
|
42
|
+
.option('--output <dest>', 'Output destination: json (default), csv, postgres://..., sqlite:///path')
|
|
43
|
+
.option('--file <path>', 'File path for CSV output (default: ./sheetlink-transactions.csv)')
|
|
44
|
+
.option('--item <item_id>', 'Sync a specific item only (default: all connected banks)')
|
|
45
|
+
.addHelpText('after', `
|
|
46
|
+
Examples:
|
|
47
|
+
sheetlink sync JSON to stdout (pipeable)
|
|
48
|
+
sheetlink sync | jq '.items[].transactions | length' Count transactions
|
|
49
|
+
sheetlink sync --output csv Snapshot CSV (overwrites each run)
|
|
50
|
+
sheetlink sync --output csv --file ~/finances.csv CSV to custom path
|
|
51
|
+
sheetlink sync --output postgres://localhost/mydb Upsert to Postgres (MAX only)
|
|
52
|
+
sheetlink sync --output sqlite:///~/finance.db Upsert to SQLite (MAX only)
|
|
53
|
+
sheetlink sync --item VBX93wmRY4Iy... Sync one bank only
|
|
54
|
+
`)
|
|
55
|
+
.action(cmdSync);
|
|
56
|
+
|
|
57
|
+
// ── items ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
program
|
|
60
|
+
.command('items')
|
|
61
|
+
.description('List connected bank accounts')
|
|
62
|
+
.action(cmdItems);
|
|
63
|
+
|
|
64
|
+
// ── config ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
program
|
|
67
|
+
.command('config')
|
|
68
|
+
.description('Show or update CLI configuration')
|
|
69
|
+
.option('--set <key=value>', 'Set a config value (e.g. --set default_output=csv)')
|
|
70
|
+
.addHelpText('after', `
|
|
71
|
+
Settable keys:
|
|
72
|
+
default_output Default output destination (json, csv, postgres://..., sqlite://...)
|
|
73
|
+
api_url Backend API URL (default: https://api.sheetlink.app)
|
|
74
|
+
|
|
75
|
+
Environment variable overrides:
|
|
76
|
+
SHEETLINK_API_KEY API key (overrides config file)
|
|
77
|
+
SHEETLINK_OUTPUT Default output (overrides config file)
|
|
78
|
+
SHEETLINK_API_URL Backend URL (overrides config file)
|
|
79
|
+
`)
|
|
80
|
+
.action(cmdConfig);
|
|
81
|
+
|
|
82
|
+
program.parse();
|