@profullstack/coinpay 0.12.0 → 0.13.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/CHANGELOG.md +27 -0
- package/bin/coinpay.js +47 -4
- package/package.json +5 -1
- package/src/client.js +5 -0
- package/src/finances-commands.js +88 -3
- package/src/finances-reports.js +34 -0
- package/src/mcp.js +17 -0
- package/src/oauth-login.js +174 -0
- package/src/statements-fetch.d.ts +123 -0
- package/src/statements-fetch.js +99 -9
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay login` signs in with OAuth 2.1: authorization code + PKCE over a
|
|
12
|
+
loopback redirect, as the public `coinpay-cli` client, with single-use
|
|
13
|
+
refresh tokens rotated before any command whose access token is about to
|
|
14
|
+
expire. `coinpay login --device` keeps the device flow for SSH.
|
|
15
|
+
- `coinpay finances statements cloud [status|connect|fetch|schedule|forget]`:
|
|
16
|
+
CoinPay cloud statement fetching (Professional plan, free for admins).
|
|
17
|
+
`connect` opens CoinPay's cloud browser to sign in to the bank once; CoinPay
|
|
18
|
+
keeps the bank's session sealed and fetches every week.
|
|
19
|
+
- MCP: `statements_cloud_status`, `statements_cloud_fetch`.
|
|
20
|
+
- `@profullstack/coinpay/statements` export with types; `openBrowser` takes
|
|
21
|
+
`extraArgs` and `handleSignals`; `CoinPayClient#baseUrl` getter.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- The statement fetcher no longer attaches to Chrome's own omnibox page.
|
|
25
|
+
|
|
26
|
+
## [0.12.1] - 2026-10-04
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- `coinpay finances statements login|fetch|assist` no longer reports "profile is
|
|
30
|
+
open in another Chrome" after an interrupted run. A lock left by a process
|
|
31
|
+
that is gone, or now belongs to another program, is removed. Our own leftover
|
|
32
|
+
headless Chrome is stopped. A visible window is closed only with `--force`.
|
|
33
|
+
Ctrl+C or a crash now takes the Chrome it started down with it.
|
|
34
|
+
|
|
8
35
|
## [0.12.0] - 2026-10-04
|
|
9
36
|
|
|
10
37
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -165,6 +165,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
165
165
|
'wait',
|
|
166
166
|
'strict',
|
|
167
167
|
'headed',
|
|
168
|
+
'device',
|
|
169
|
+
'force',
|
|
168
170
|
'setup-token-stdin',
|
|
169
171
|
'overwrite',
|
|
170
172
|
'no-pending',
|
|
@@ -379,7 +381,7 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
379
381
|
|
|
380
382
|
${colors.bright}auth${colors.reset}
|
|
381
383
|
register Register new merchant account
|
|
382
|
-
login
|
|
384
|
+
login Sign in (OAuth 2.1 in your browser; --device on a machine without one)
|
|
383
385
|
me Show current merchant info
|
|
384
386
|
|
|
385
387
|
${colors.bright}payment${colors.reset}
|
|
@@ -491,6 +493,10 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
491
493
|
statements assist <bank> A window: every PDF you download is imported
|
|
492
494
|
statements coverage Which account-months have a statement (--months 12)
|
|
493
495
|
statements runs|local|retry Fetch history, the local archive, re-import what failed
|
|
496
|
+
statements cloud [status] CoinPay cloud fetching (Professional; free for admins)
|
|
497
|
+
statements cloud connect <bank> Sign in once in CoinPay's cloud browser (opens your browser)
|
|
498
|
+
statements cloud fetch [bank] [--wait] Fetch now in the cloud; weekly by default
|
|
499
|
+
statements cloud schedule <bank> weekly|off | cloud forget <bank>
|
|
494
500
|
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
495
501
|
books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
|
|
496
502
|
books confirm-all Accept every pending suggestion (--yes)
|
|
@@ -3780,7 +3786,26 @@ async function runInteractiveMenu() {
|
|
|
3780
3786
|
// Headless login: ask the server for a device code, show a URL to approve on any
|
|
3781
3787
|
// device, and poll until a merchant session token is minted and stored. No
|
|
3782
3788
|
// password on this machine — works over SSH / on a server.
|
|
3783
|
-
async function handleLogin() {
|
|
3789
|
+
async function handleLogin(flags = {}) {
|
|
3790
|
+
// OAuth 2.1 (authorization code + PKCE, loopback redirect, rotating refresh)
|
|
3791
|
+
// unless asked for the device flow, which is what works over SSH.
|
|
3792
|
+
if (!flags.device) {
|
|
3793
|
+
const cfg = loadConfig();
|
|
3794
|
+
const { loopbackLogin, toStoredSession } = await import('../src/oauth-login.js');
|
|
3795
|
+
try {
|
|
3796
|
+
const tokens = await loopbackLogin({ apiBase: cfg.baseUrl, log: (line) => console.log(line) });
|
|
3797
|
+
cfg.oauth = toStoredSession(tokens);
|
|
3798
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
3799
|
+
saveConfig(cfg);
|
|
3800
|
+
const me = await fetchMe();
|
|
3801
|
+
print.success(me ? `Logged in as ${meLabel(me)} (OAuth 2.1)` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
3802
|
+
return;
|
|
3803
|
+
} catch (err) {
|
|
3804
|
+
print.error(`Browser sign-in failed: ${err.message}`);
|
|
3805
|
+
print.info('On a machine without a local browser, use: coinpay login --device');
|
|
3806
|
+
process.exit(1);
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3784
3809
|
const cfg = loadConfig();
|
|
3785
3810
|
const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
|
|
3786
3811
|
|
|
@@ -3847,6 +3872,7 @@ async function handleLogin() {
|
|
|
3847
3872
|
function handleLogout() {
|
|
3848
3873
|
const cfg = loadConfig();
|
|
3849
3874
|
delete cfg.jwtToken;
|
|
3875
|
+
delete cfg.oauth;
|
|
3850
3876
|
saveConfig(cfg);
|
|
3851
3877
|
print.success('Logged out — session removed from ' + CONFIG_FILE);
|
|
3852
3878
|
}
|
|
@@ -4488,9 +4514,21 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4488
4514
|
}
|
|
4489
4515
|
}
|
|
4490
4516
|
|
|
4517
|
+
// An OAuth 2.1 session's access token lasts an hour: rotate it before any
|
|
4518
|
+
// command that might use it, keeping the new single-use refresh token.
|
|
4519
|
+
if (!['login', 'logout', 'help', 'version', '--version', '-v'].includes(command)) {
|
|
4520
|
+
const sessionCfg = loadConfig();
|
|
4521
|
+
if (sessionCfg.oauth) {
|
|
4522
|
+
const { ensureFreshSession } = await import('../src/oauth-login.js');
|
|
4523
|
+
if (!(await ensureFreshSession(sessionCfg, saveConfig))) {
|
|
4524
|
+
print.warn('Your CoinPay sign-in expired. Run: coinpay login');
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4491
4529
|
switch (command) {
|
|
4492
4530
|
case 'login':
|
|
4493
|
-
await handleLogin();
|
|
4531
|
+
await handleLogin(flags);
|
|
4494
4532
|
break;
|
|
4495
4533
|
|
|
4496
4534
|
case 'logout':
|
|
@@ -4582,7 +4620,12 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4582
4620
|
await serveStdio({
|
|
4583
4621
|
version: VERSION,
|
|
4584
4622
|
getClient: async () => {
|
|
4585
|
-
const
|
|
4623
|
+
const cfg = loadConfig();
|
|
4624
|
+
if (cfg.oauth) {
|
|
4625
|
+
const { ensureFreshSession } = await import('../src/oauth-login.js');
|
|
4626
|
+
await ensureFreshSession(cfg, saveConfig);
|
|
4627
|
+
}
|
|
4628
|
+
const token = process.env.COINPAY_SESSION_TOKEN || cfg.jwtToken;
|
|
4586
4629
|
if (!token) throw new Error('Not logged in: run `coinpay login` (finance tools need the merchant session)');
|
|
4587
4630
|
return new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 });
|
|
4588
4631
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"import": "./src/swap.js",
|
|
38
38
|
"types": "./src/swap.d.ts"
|
|
39
39
|
},
|
|
40
|
+
"./statements": {
|
|
41
|
+
"import": "./src/statements-fetch.js",
|
|
42
|
+
"types": "./src/statements-fetch.d.ts"
|
|
43
|
+
},
|
|
40
44
|
"./x402": {
|
|
41
45
|
"import": "./src/x402.js"
|
|
42
46
|
},
|
package/src/client.js
CHANGED
|
@@ -31,6 +31,11 @@ export class CoinPayClient {
|
|
|
31
31
|
this.lightning = new LightningClient(this);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The API base this client talks to (read-only). */
|
|
35
|
+
get baseUrl() {
|
|
36
|
+
return this.#baseUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
/**
|
|
35
40
|
* Make an authenticated API request
|
|
36
41
|
* @param {string} endpoint - API endpoint
|
package/src/finances-commands.js
CHANGED
|
@@ -123,7 +123,7 @@ function describeReport(report) {
|
|
|
123
123
|
return parts.filter(Boolean).join(' · ');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry']);
|
|
126
|
+
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry', 'cloud']);
|
|
127
127
|
|
|
128
128
|
function ago(iso) {
|
|
129
129
|
if (!iso) return 'never';
|
|
@@ -132,6 +132,90 @@ function ago(iso) {
|
|
|
132
132
|
return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `coinpay finances statements cloud …`: the same statement fetching, run by
|
|
137
|
+
* CoinPay cloud with a bank session the merchant saved through CoinPay's
|
|
138
|
+
* cloud browser. Professional plan; free for admins.
|
|
139
|
+
*/
|
|
140
|
+
async function runCloudAction(rest, flags, { client, emit, progress }) {
|
|
141
|
+
const [sub = 'status', name] = rest;
|
|
142
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
|
|
144
|
+
if (sub === 'status' || sub === 'banks') {
|
|
145
|
+
const data = await api.getCloudStatements(client);
|
|
146
|
+
const lines = [data.access.allowed ? `CoinPay cloud: ${data.access.message}` : `CoinPay cloud is not available: ${data.access.message}`, ''];
|
|
147
|
+
for (const bank of data.banks) {
|
|
148
|
+
const c = bank.cloud;
|
|
149
|
+
lines.push(`${bank.key} ${bank.name} · ${!c ? 'not connected' : c.state === 'login_needed' ? 'needs sign-in' : c.state}${c ? ` · last fetch ${ago(c.lastFetchAt)}${c.lastStatus ? ` (${c.lastStatus})` : ''} · ${c.schedule}` : ''}`);
|
|
150
|
+
}
|
|
151
|
+
const next = data.banks.find((b) => !b.cloud || b.cloud.state !== 'active');
|
|
152
|
+
if (data.access.allowed && next) lines.push('', `Next: coinpay finances statements cloud connect ${next.key}`);
|
|
153
|
+
emit(data, lines.join('\n'));
|
|
154
|
+
return EXIT.OK;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (sub === 'connect') {
|
|
158
|
+
if (!name) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud connect <bank> [--url <sign-in page>]');
|
|
159
|
+
const data = await api.connectCloudBank(client, { institutionKey: name, url: typeof flags.url === 'string' ? flags.url : undefined });
|
|
160
|
+
const { siteOrigin, openInBrowser } = await import('./oauth-login.js');
|
|
161
|
+
const viewer = new URL(data.viewerUrl, siteOrigin(client.baseUrl)).toString();
|
|
162
|
+
const opened = flags['no-open'] ? false : openInBrowser(viewer);
|
|
163
|
+
progress(`${opened ? 'Opened' : 'Open'} ${viewer}`);
|
|
164
|
+
progress('Sign in to your bank there, go to the page that lists your statements, and press Save. Waiting…');
|
|
165
|
+
for (;;) {
|
|
166
|
+
await pause(3000);
|
|
167
|
+
let live;
|
|
168
|
+
try {
|
|
169
|
+
live = await api.getCloudLiveSession(client, data.live.id);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err.status === 404) throw new CliExit(EXIT.FAILURE, 'The sign-in session is gone (the server restarted?). Run the command again.');
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
if (live.status === 'saved') {
|
|
175
|
+
emit({ live }, `Saved. ${name} is connected to CoinPay cloud and its first fetch is queued: coinpay finances statements cloud status`);
|
|
176
|
+
return EXIT.OK;
|
|
177
|
+
}
|
|
178
|
+
if (['cancelled', 'expired', 'failed'].includes(live.status)) {
|
|
179
|
+
throw new CliExit(EXIT.FAILURE, `The sign-in session ended: ${live.status}${live.error ? ` (${live.error})` : ''}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sub === 'fetch') {
|
|
185
|
+
const data = await api.fetchCloudStatements(client, { institutionKey: name });
|
|
186
|
+
if (!flags.wait) {
|
|
187
|
+
emit(data, data.jobs.map((j) => `queued ${j.id} (${j.params?.institutionKey ?? ''})`).join('\n') + '\nFollow with: coinpay finances statements runs');
|
|
188
|
+
return EXIT.OK;
|
|
189
|
+
}
|
|
190
|
+
const jobs = new Map(data.jobs.map((j) => [j.id, j]));
|
|
191
|
+
const deadline = Date.now() + 20 * 60_000;
|
|
192
|
+
while (Date.now() < deadline && [...jobs.values()].some((j) => ['queued', 'running', 'waiting_for_budget'].includes(j.status))) {
|
|
193
|
+
await pause(5000);
|
|
194
|
+
for (const id of jobs.keys()) jobs.set(id, await api.getFinanceJob(client, id));
|
|
195
|
+
}
|
|
196
|
+
const done = [...jobs.values()];
|
|
197
|
+
emit({ jobs: done }, done.map((j) => `${j.params?.institutionKey ?? j.id}: ${j.status}${j.result ? `, ${j.result.imported ?? 0} imported, ${j.result.duplicates ?? 0} already had` : ''}${j.errorMessage ? ` (${j.errorMessage})` : ''}`).join('\n'));
|
|
198
|
+
return done.every((j) => j.status === 'completed') ? EXIT.OK : EXIT.STRICT;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (sub === 'schedule') {
|
|
202
|
+
const value = rest[2];
|
|
203
|
+
if (!name || (value !== 'weekly' && value !== 'off')) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud schedule <bank> weekly|off');
|
|
204
|
+
const data = await api.setCloudSchedule(client, name, value);
|
|
205
|
+
emit(data, `${name}: ${value === 'weekly' ? 'fetched every week' : 'schedule off'}`);
|
|
206
|
+
return EXIT.OK;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (sub === 'forget' || sub === 'disconnect') {
|
|
210
|
+
if (!name) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud forget <bank>');
|
|
211
|
+
const data = await api.disconnectCloudBank(client, name);
|
|
212
|
+
emit(data, data.note);
|
|
213
|
+
return EXIT.OK;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud [status|connect <bank>|fetch [bank] [--wait]|schedule <bank> weekly|off|forget <bank>]');
|
|
217
|
+
}
|
|
218
|
+
|
|
135
219
|
/**
|
|
136
220
|
* Downloading statements from the banks themselves (SimpleFIN has no
|
|
137
221
|
* documents). Runs on this machine: each bank's session lives in a local
|
|
@@ -150,6 +234,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
150
234
|
};
|
|
151
235
|
|
|
152
236
|
try {
|
|
237
|
+
if (action === 'cloud') return await runCloudAction(rest, flags, { client, out, emit, progress });
|
|
153
238
|
if (action === 'coverage') {
|
|
154
239
|
const data = await api.getStatementCoverage(client, { months: months(flags.months, 12) });
|
|
155
240
|
const mark = { have: '■', missing: '·', open: '○' };
|
|
@@ -201,7 +286,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
201
286
|
const state = sf.loadLocal(home);
|
|
202
287
|
const local = (state.institutions[institution.key] ||= {});
|
|
203
288
|
const urls = sf.startUrls(institution, local.start);
|
|
204
|
-
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false });
|
|
289
|
+
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false, force: flags.force === true });
|
|
205
290
|
if (action === 'login') {
|
|
206
291
|
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
207
292
|
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
@@ -239,7 +324,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
239
324
|
if (!Number.isInteger(max) || max < 1 || max > 500) throw new CliExit(EXIT.INVALID, '--max is a whole number from 1 to 500');
|
|
240
325
|
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
241
326
|
if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
|
|
242
|
-
const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress });
|
|
327
|
+
const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress, force: flags.force === true });
|
|
243
328
|
emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} imported, ${r.duplicates} already had, ${r.unmatched} unmatched${r.message ? ` (${r.message})` : ''}`).join('\n'));
|
|
244
329
|
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
245
330
|
} catch (err) {
|
package/src/finances-reports.js
CHANGED
|
@@ -272,6 +272,40 @@ export async function getStatementCoverage(client, { months } = {}) {
|
|
|
272
272
|
return call(client, `/finances/statements/coverage${query({ months })}`);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ── Cloud statement fetching (Professional plan; free for admins) ──
|
|
276
|
+
|
|
277
|
+
/** `{access, banks}`: whether cloud fetching is available, and each bank's cloud connection. */
|
|
278
|
+
export async function getCloudStatements(client) {
|
|
279
|
+
return call(client, '/finances/statements/cloud');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Open a bank in CoinPay's cloud browser to sign in. Answers `{live, viewerUrl}`. */
|
|
283
|
+
export async function connectCloudBank(client, { institutionKey, url } = {}) {
|
|
284
|
+
return call(client, '/finances/statements/cloud/connect', { method: 'POST', body: JSON.stringify({ institutionKey, url }) });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function getCloudLiveSession(client, liveId) {
|
|
288
|
+
const data = await call(client, `/finances/statements/cloud/live/${encodeURIComponent(liveId)}`);
|
|
289
|
+
return data.live;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function cancelCloudLiveSession(client, liveId) {
|
|
293
|
+
return call(client, `/finances/statements/cloud/live/${encodeURIComponent(liveId)}`, { method: 'DELETE' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Queue cloud fetches for one bank or all connected ones. Answers `{jobs}`. */
|
|
297
|
+
export async function fetchCloudStatements(client, { institutionKey } = {}) {
|
|
298
|
+
return call(client, '/finances/statements/cloud/fetch', { method: 'POST', body: JSON.stringify(institutionKey ? { institutionKey } : {}) });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function setCloudSchedule(client, institutionKey, schedule) {
|
|
302
|
+
return call(client, `/finances/statements/cloud/banks/${encodeURIComponent(institutionKey)}`, { method: 'PATCH', body: JSON.stringify({ schedule }) });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function disconnectCloudBank(client, institutionKey) {
|
|
306
|
+
return call(client, `/finances/statements/cloud/banks/${encodeURIComponent(institutionKey)}`, { method: 'DELETE' });
|
|
307
|
+
}
|
|
308
|
+
|
|
275
309
|
// ── Books ──
|
|
276
310
|
|
|
277
311
|
/** Rows awaiting review (default) plus the category vocabularies. */
|
package/src/mcp.js
CHANGED
|
@@ -63,6 +63,23 @@ export const TOOLS = [
|
|
|
63
63
|
});
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'statements_cloud_status',
|
|
68
|
+
description:
|
|
69
|
+
'CoinPay cloud statement fetching: whether this account has it (Professional plan, free for admins) and, per bank, whether a cloud session is connected, needs sign-in again, and when it last fetched. A bank must be connected by a person in the PWA or with `coinpay finances statements cloud connect <bank>`.',
|
|
70
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
71
|
+
handler: async (_args, { client }) => text(await reports.getCloudStatements(client)),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'statements_cloud_fetch',
|
|
75
|
+
description: 'Queue a CoinPay cloud fetch of new PDF statements for one connected bank, or all of them. Runs on CoinPay servers; returns the jobs. Nothing runs on this machine.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: { bank: { type: 'string', description: 'Bank key such as "chase"; every connected bank when omitted' } },
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
handler: async (args, { client }) => text(await reports.fetchCloudStatements(client, { institutionKey: args.bank })),
|
|
82
|
+
},
|
|
66
83
|
{
|
|
67
84
|
name: 'statements_fetch',
|
|
68
85
|
description:
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coinpay login` over OAuth 2.1: authorization code + PKCE, a loopback
|
|
3
|
+
* redirect, and rotating refresh tokens.
|
|
4
|
+
*
|
|
5
|
+
* The CLI is a public client (`coinpay-cli`, no secret). It listens on
|
|
6
|
+
* 127.0.0.1 at a free port, opens the browser at CoinPay's /api/oauth/authorize
|
|
7
|
+
* with an S256 code challenge and a random state, catches the redirect, checks
|
|
8
|
+
* the state, and exchanges the code with its verifier. The access token lasts
|
|
9
|
+
* an hour; the refresh token is single-use, so each refresh stores the new
|
|
10
|
+
* one. Over SSH, where the browser cannot reach this machine's loopback, the
|
|
11
|
+
* device flow (`coinpay login --device`) is the way in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
export const CLI_CLIENT_ID = 'coinpay-cli';
|
|
19
|
+
export const CLI_SCOPE = 'openid profile email merchant';
|
|
20
|
+
|
|
21
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
+
|
|
23
|
+
/** A PKCE verifier (43+ chars of base64url) and its S256 challenge. */
|
|
24
|
+
export function pkcePair() {
|
|
25
|
+
const verifier = b64url(randomBytes(32));
|
|
26
|
+
const challenge = b64url(createHash('sha256').update(verifier).digest());
|
|
27
|
+
return { verifier, challenge };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The site origin behind an API base such as https://coinpayportal.com/api. */
|
|
31
|
+
export function siteOrigin(apiBase) {
|
|
32
|
+
return new URL(apiBase || 'https://coinpayportal.com/api').origin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function authorizeUrl({ origin, redirectUri, challenge, state, clientId = CLI_CLIENT_ID, scope = CLI_SCOPE }) {
|
|
36
|
+
const url = new URL('/api/oauth/authorize', origin);
|
|
37
|
+
url.searchParams.set('response_type', 'code');
|
|
38
|
+
url.searchParams.set('client_id', clientId);
|
|
39
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
40
|
+
url.searchParams.set('scope', scope);
|
|
41
|
+
url.searchParams.set('state', state);
|
|
42
|
+
url.searchParams.set('code_challenge', challenge);
|
|
43
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function tokenRequest(origin, params, fetchImpl = fetch) {
|
|
48
|
+
const res = await fetchImpl(new URL('/api/oauth/token', origin), {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
51
|
+
body: new URLSearchParams(params).toString(),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok || !data.access_token) {
|
|
55
|
+
const err = new Error(data.error_description || data.error || `token endpoint answered ${res.status}`);
|
|
56
|
+
err.code = data.error || 'token_error';
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the CLI keeps: the access token is also stored as `jwtToken`, which every command already sends. */
|
|
63
|
+
export function toStoredSession(tokens, now = Date.now()) {
|
|
64
|
+
return {
|
|
65
|
+
accessToken: tokens.access_token,
|
|
66
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
67
|
+
expiresAt: new Date(now + (Number(tokens.expires_in) || 3600) * 1000).toISOString(),
|
|
68
|
+
scope: tokens.scope ?? CLI_SCOPE,
|
|
69
|
+
clientId: CLI_CLIENT_ID,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function exchangeCode({ origin, code, verifier, redirectUri, clientId = CLI_CLIENT_ID, fetchImpl }) {
|
|
74
|
+
return tokenRequest(origin, { grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectUri, client_id: clientId }, fetchImpl);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function refreshTokens({ origin, refreshToken, clientId = CLI_CLIENT_ID, fetchImpl }) {
|
|
78
|
+
return tokenRequest(origin, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId }, fetchImpl);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function openInBrowser(url) {
|
|
82
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
83
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
84
|
+
try {
|
|
85
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
86
|
+
child.on('error', () => {});
|
|
87
|
+
child.unref();
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DONE_PAGE = (ok, message) => `<!doctype html><meta charset="utf-8"><title>CoinPay CLI</title>
|
|
95
|
+
<body style="font-family:system-ui;background:#0b1120;color:#e5e7eb;display:grid;place-items:center;height:100vh;margin:0">
|
|
96
|
+
<div><h1 style="font-size:20px">${ok ? 'Signed in to the CoinPay CLI' : 'Sign-in failed'}</h1><p>${message}</p></div></body>`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the whole loopback flow. Resolves with the token response.
|
|
100
|
+
* `open` is how the browser is launched; `log` prints the URL for a human.
|
|
101
|
+
*/
|
|
102
|
+
export async function loopbackLogin({ apiBase, open = openInBrowser, log = () => {}, timeoutMs = 5 * 60_000, fetchImpl } = {}) {
|
|
103
|
+
const origin = siteOrigin(apiBase);
|
|
104
|
+
const { verifier, challenge } = pkcePair();
|
|
105
|
+
const state = b64url(randomBytes(16));
|
|
106
|
+
|
|
107
|
+
let settle;
|
|
108
|
+
const result = new Promise((resolve, reject) => {
|
|
109
|
+
settle = { resolve, reject };
|
|
110
|
+
});
|
|
111
|
+
const server = createServer((req, res) => {
|
|
112
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
113
|
+
if (url.pathname !== '/callback') {
|
|
114
|
+
res.writeHead(404).end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const error = url.searchParams.get('error');
|
|
118
|
+
const code = url.searchParams.get('code');
|
|
119
|
+
if (url.searchParams.get('state') !== state) {
|
|
120
|
+
res.writeHead(400, { 'Content-Type': 'text/html' }).end(DONE_PAGE(false, 'The response did not match this login. Run coinpay login again.'));
|
|
121
|
+
settle.reject(new Error('state mismatch: the callback was not for this login'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (error || !code) {
|
|
125
|
+
res.writeHead(400, { 'Content-Type': 'text/html' }).end(DONE_PAGE(false, error === 'access_denied' ? 'You declined.' : 'No code came back.'));
|
|
126
|
+
settle.reject(new Error(error === 'access_denied' ? 'access denied' : `authorization failed: ${error || 'no code'}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'text/html' }).end(DONE_PAGE(true, 'You can close this tab and go back to the terminal.'));
|
|
130
|
+
settle.resolve(code);
|
|
131
|
+
});
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
server.once('error', reject);
|
|
134
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
135
|
+
});
|
|
136
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
|
|
137
|
+
const url = authorizeUrl({ origin, redirectUri, challenge, state });
|
|
138
|
+
const timer = setTimeout(() => settle.reject(new Error('timed out waiting for the browser sign-in')), timeoutMs);
|
|
139
|
+
try {
|
|
140
|
+
const opened = open(url);
|
|
141
|
+
log(opened ? `Opening your browser to sign in. If it did not open, visit:\n ${url}` : `Open this URL to sign in:\n ${url}`);
|
|
142
|
+
const code = await result;
|
|
143
|
+
return await exchangeCode({ origin, code, verifier, redirectUri, fetchImpl });
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
server.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Before any command: if the stored OAuth access token is within a minute of
|
|
152
|
+
* expiring, rotate it. Mutates and saves `cfg`. Returns false when the
|
|
153
|
+
* refresh token is no longer good (sign in again).
|
|
154
|
+
*/
|
|
155
|
+
export async function ensureFreshSession(cfg, saveConfig, { now = Date.now(), fetchImpl } = {}) {
|
|
156
|
+
const session = cfg.oauth;
|
|
157
|
+
if (!session || !session.refreshToken) return true;
|
|
158
|
+
if (Date.parse(session.expiresAt) - now > 60_000) return true;
|
|
159
|
+
try {
|
|
160
|
+
const tokens = await refreshTokens({ origin: siteOrigin(cfg.baseUrl), refreshToken: session.refreshToken, clientId: session.clientId || CLI_CLIENT_ID, fetchImpl });
|
|
161
|
+
cfg.oauth = toStoredSession(tokens, now);
|
|
162
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
163
|
+
saveConfig(cfg);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err.code === 'invalid_grant') {
|
|
167
|
+
delete cfg.oauth;
|
|
168
|
+
delete cfg.jwtToken;
|
|
169
|
+
saveConfig(cfg);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true; // a network blip: let the command try with what it has
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Types for `@profullstack/coinpay/statements` (src/statements-fetch.js). */
|
|
2
|
+
|
|
3
|
+
export class StatementFetchError extends Error {}
|
|
4
|
+
|
|
5
|
+
export interface FetcherAccount {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
last4: string | null;
|
|
9
|
+
institution: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Institution {
|
|
13
|
+
key: string;
|
|
14
|
+
name: string;
|
|
15
|
+
url: string | null;
|
|
16
|
+
accounts: FetcherAccount[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Period {
|
|
20
|
+
month: string;
|
|
21
|
+
from: string | null;
|
|
22
|
+
to: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ImportSpan {
|
|
26
|
+
period?: string;
|
|
27
|
+
from?: string;
|
|
28
|
+
to?: string;
|
|
29
|
+
cycle?: 'custom';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CdpEvent {
|
|
33
|
+
method: string;
|
|
34
|
+
params: Record<string, unknown>;
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Cdp {
|
|
39
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
|
|
40
|
+
on(listener: (event: CdpEvent) => void): () => void;
|
|
41
|
+
waitFor(method: string, sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Browser {
|
|
45
|
+
cdp: Cdp;
|
|
46
|
+
exited: Promise<void>;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Download {
|
|
51
|
+
bytes: Buffer;
|
|
52
|
+
suggestedName: string;
|
|
53
|
+
label: string;
|
|
54
|
+
context: string;
|
|
55
|
+
key: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PageStatus = 'ok' | 'login_needed' | 'no_statements';
|
|
59
|
+
|
|
60
|
+
export function lastFour(name: string): string | null;
|
|
61
|
+
export function slug(value: string): string;
|
|
62
|
+
export function institutionKey(domain: string | null | undefined, name: string | null | undefined): string;
|
|
63
|
+
export function groupInstitutions(accounts: ReadonlyArray<{ id: string; name: string; org_name?: string | null; org_domain?: string | null; is_hidden?: boolean }>): Institution[];
|
|
64
|
+
export function pickInstitution(institutions: readonly Institution[], name: string): Institution;
|
|
65
|
+
export const DRIVERS: ReadonlyArray<{ key: string; login: string; statements?: string }>;
|
|
66
|
+
export function startUrls(institution: { key: string; url: string | null }, learnt?: string | null): { login: string | null; fetch: string | null };
|
|
67
|
+
export function isSignInUrl(url: string): boolean;
|
|
68
|
+
export function findDates(source: string | null | undefined): { date: string; precise: boolean }[];
|
|
69
|
+
export function periodOf(...sources: ReadonlyArray<string | null | undefined>): Period | null;
|
|
70
|
+
export function matchAccount(accounts: readonly FetcherAccount[], ...sources: ReadonlyArray<string | null | undefined>): FetcherAccount | null;
|
|
71
|
+
export function importPeriod(period: Period | null): ImportSpan | null;
|
|
72
|
+
export function candidateKey(candidate: { label: string; context: string; href: string | null }): string;
|
|
73
|
+
export function isPdf(bytes: Uint8Array): boolean;
|
|
74
|
+
export function sha256(bytes: Uint8Array): string;
|
|
75
|
+
export function statementsHome(env?: Record<string, string | undefined>): string;
|
|
76
|
+
export function profileDir(key: string, home?: string): string;
|
|
77
|
+
export function signedIn(key: string, home?: string): boolean;
|
|
78
|
+
export function findChrome(env?: Record<string, string | undefined>, home?: string): string | null;
|
|
79
|
+
export const NO_CHROME: string;
|
|
80
|
+
export function profileLock(profile: string): { host: string; pid: number } | null;
|
|
81
|
+
export function releaseProfile(profile: string, options?: { force?: boolean }): Promise<'free' | 'stale' | 'stopped'>;
|
|
82
|
+
export function preferPdfDownloads(profile: string): void;
|
|
83
|
+
export function openBrowser(options: {
|
|
84
|
+
chrome: string;
|
|
85
|
+
profile: string;
|
|
86
|
+
headless?: boolean;
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
env?: Record<string, string | undefined>;
|
|
89
|
+
force?: boolean;
|
|
90
|
+
extraArgs?: string[];
|
|
91
|
+
handleSignals?: boolean;
|
|
92
|
+
}): Promise<Browser>;
|
|
93
|
+
export const SIGNED_OUT: string;
|
|
94
|
+
export const OPEN_STATEMENTS: string;
|
|
95
|
+
export const COLLECT: string;
|
|
96
|
+
export const SECOND_STEP: string;
|
|
97
|
+
export const CLICK_RECORDER: string;
|
|
98
|
+
export function clickScript(index: number): string;
|
|
99
|
+
export function fetchInstitution(
|
|
100
|
+
browser: Browser,
|
|
101
|
+
options: {
|
|
102
|
+
start: string;
|
|
103
|
+
seen?: Set<string>;
|
|
104
|
+
since?: string | null;
|
|
105
|
+
max?: number;
|
|
106
|
+
renderMs?: number;
|
|
107
|
+
onFile: (download: Download) => Promise<void> | void;
|
|
108
|
+
log?: (line: string) => void;
|
|
109
|
+
},
|
|
110
|
+
): Promise<{ status: PageStatus; url: string; candidates: number; silent: string[] }>;
|
|
111
|
+
export function loginWindow(browser: Browser, url: string): Promise<string | null>;
|
|
112
|
+
export function assistWindow(browser: Browser, options: { start: string; onFile: (download: Download) => Promise<void> | void }): Promise<number>;
|
|
113
|
+
export function loadLocal(home?: string): { institutions: Record<string, Record<string, unknown>>; entries: Record<string, unknown>[] };
|
|
114
|
+
export function saveLocal(state: unknown, home?: string): void;
|
|
115
|
+
export function archive(home: string, institution: Institution, account: FetcherAccount | null, period: Period | null, suggestedName: string, bytes: Uint8Array): string;
|
|
116
|
+
export function keepStatement(options: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
117
|
+
export function runStatementFetch(options: Record<string, unknown>): Promise<Record<string, unknown>[]>;
|
|
118
|
+
export function clientApi(client: unknown): Promise<{
|
|
119
|
+
listAccounts(): Promise<unknown[]>;
|
|
120
|
+
importStatement(options: Record<string, unknown>): Promise<unknown>;
|
|
121
|
+
reportRun(run: Record<string, unknown>): Promise<unknown>;
|
|
122
|
+
}>;
|
|
123
|
+
export function retryImports(options: Record<string, unknown>): Promise<{ imported: number; skipped: number; failed: number }>;
|
package/src/statements-fetch.js
CHANGED
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* download or PDF control) and says what it saw. Needs Node 22+ (WebSocket).
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { spawn } from 'node:child_process';
|
|
26
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
27
27
|
import { createHash } from 'node:crypto';
|
|
28
|
-
import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
-
import { homedir, tmpdir } from 'node:os';
|
|
28
|
+
import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
30
30
|
import { basename, dirname, join } from 'node:path';
|
|
31
31
|
|
|
32
32
|
export class StatementFetchError extends Error {
|
|
@@ -420,10 +420,83 @@ export function preferPdfDownloads(profile) {
|
|
|
420
420
|
* cookies only reach disk then: a killed Chrome forgets a session the bank
|
|
421
421
|
* refreshed during the run.
|
|
422
422
|
*/
|
|
423
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Who holds a profile. Chrome marks a profile in use with a SingletonLock
|
|
425
|
+
* symlink whose target is "<hostname>-<pid>" (Linux and macOS).
|
|
426
|
+
*/
|
|
427
|
+
export function profileLock(profile) {
|
|
428
|
+
try {
|
|
429
|
+
const target = readlinkSync(join(profile, 'SingletonLock'));
|
|
430
|
+
const dash = target.lastIndexOf('-');
|
|
431
|
+
const pid = Number(target.slice(dash + 1));
|
|
432
|
+
return dash > 0 && Number.isInteger(pid) ? { host: target.slice(0, dash), pid } : null;
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function alive(pid) {
|
|
439
|
+
try {
|
|
440
|
+
process.kill(pid, 0);
|
|
441
|
+
return true;
|
|
442
|
+
} catch (err) {
|
|
443
|
+
return err.code === 'EPERM';
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The command line of a process, or '' when it cannot be read. */
|
|
448
|
+
function commandLine(pid) {
|
|
449
|
+
try {
|
|
450
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
451
|
+
} catch {
|
|
452
|
+
const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' });
|
|
453
|
+
return ps.status === 0 ? ps.stdout : '';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function removeLock(profile) {
|
|
458
|
+
for (const name of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) rmSync(join(profile, name), { force: true });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Make sure no other Chrome holds `profile` before we start one on it.
|
|
463
|
+
*
|
|
464
|
+
* A login or fetch that was interrupted (Ctrl+C, a closed terminal, a crash)
|
|
465
|
+
* can leave Chrome running on the profile, or a lock pointing at a process
|
|
466
|
+
* that is gone. Chrome then hands every new launch to the old instance and
|
|
467
|
+
* exits, which looked like "open in another Chrome" forever. A dead or
|
|
468
|
+
* reused pid is a stale lock and is removed; our own leftover headless Chrome
|
|
469
|
+
* is stopped; a visible window is only closed with `force`.
|
|
470
|
+
*/
|
|
471
|
+
export async function releaseProfile(profile, { force = false } = {}) {
|
|
472
|
+
const lock = profileLock(profile);
|
|
473
|
+
if (!lock) return 'free';
|
|
474
|
+
if (lock.host !== hostname()) {
|
|
475
|
+
if (!force) throw new StatementFetchError(`the ${profile} profile is marked in use by ${lock.host}; if that machine is not using it, pass --force`);
|
|
476
|
+
removeLock(profile);
|
|
477
|
+
return 'stale';
|
|
478
|
+
}
|
|
479
|
+
const cmd = alive(lock.pid) ? commandLine(lock.pid) : '';
|
|
480
|
+
if (!cmd.includes(`--user-data-dir=${profile}`)) {
|
|
481
|
+
removeLock(profile);
|
|
482
|
+
return 'stale';
|
|
483
|
+
}
|
|
484
|
+
if (!cmd.includes('--headless') && !force) {
|
|
485
|
+
throw new StatementFetchError(`a Chrome window (pid ${lock.pid}) still has the ${basename(profile)} profile open; close it, or pass --force to close it for you`);
|
|
486
|
+
}
|
|
487
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
488
|
+
for (let i = 0; i < 50 && alive(lock.pid); i += 1) await sleep(100);
|
|
489
|
+
if (alive(lock.pid)) process.kill(lock.pid, 'SIGKILL');
|
|
490
|
+
for (let i = 0; i < 20 && alive(lock.pid); i += 1) await sleep(100);
|
|
491
|
+
removeLock(profile);
|
|
492
|
+
return 'stopped';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function openBrowser({ chrome, profile, headless = true, timeoutMs = 30_000, env = process.env, force = false, extraArgs = [], handleSignals = true }) {
|
|
424
496
|
if (typeof WebSocket === 'undefined') throw new StatementFetchError('statement fetching needs Node 22 or newer (it drives Chrome over a WebSocket)');
|
|
425
497
|
if (!chrome || !isExecutable(chrome)) throw new StatementFetchError(chrome ? `${chrome} is not an executable` : NO_CHROME);
|
|
426
498
|
mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
499
|
+
await releaseProfile(profile, { force });
|
|
427
500
|
preferPdfDownloads(profile);
|
|
428
501
|
const sandbox = !(env.CHROME_NO_SANDBOX || process.getuid?.() === 0);
|
|
429
502
|
const args = [
|
|
@@ -435,10 +508,27 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
|
|
|
435
508
|
'--disable-background-networking',
|
|
436
509
|
'--window-size=1280,900',
|
|
437
510
|
...(sandbox ? [] : ['--no-sandbox']),
|
|
511
|
+
...extraArgs,
|
|
438
512
|
'about:blank',
|
|
439
513
|
];
|
|
440
514
|
const child = spawn(chrome, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
441
515
|
const exited = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
516
|
+
// Never leave Chrome holding the profile when we go: an orphan is what
|
|
517
|
+
// makes the next run see the profile "open in another Chrome".
|
|
518
|
+
const onExit = () => { try { child.kill('SIGKILL'); } catch { /* gone */ } };
|
|
519
|
+
const onSignal = (signal) => {
|
|
520
|
+
onExit();
|
|
521
|
+
detach();
|
|
522
|
+
process.kill(process.pid, signal);
|
|
523
|
+
};
|
|
524
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
525
|
+
const detach = () => {
|
|
526
|
+
process.removeListener('exit', onExit);
|
|
527
|
+
for (const signal of signals) process.removeListener(signal, onSignal);
|
|
528
|
+
};
|
|
529
|
+
process.once('exit', onExit);
|
|
530
|
+
if (handleSignals) for (const signal of signals) process.once(signal, onSignal);
|
|
531
|
+
exited.then(detach);
|
|
442
532
|
const url = await new Promise((resolve, reject) => {
|
|
443
533
|
let stderr = '';
|
|
444
534
|
const timer = setTimeout(() => reject(new StatementFetchError(`${chrome} did not start within ${timeoutMs / 1000}s\n${stderr}`)), timeoutMs);
|
|
@@ -452,8 +542,8 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
|
|
|
452
542
|
});
|
|
453
543
|
child.on('exit', (code) => {
|
|
454
544
|
clearTimeout(timer);
|
|
455
|
-
const locked = /SingletonLock|ProcessSingleton
|
|
456
|
-
reject(new StatementFetchError(locked ? `the ${profile} profile
|
|
545
|
+
const locked = /SingletonLock|ProcessSingleton|existing browser session/i.test(stderr);
|
|
546
|
+
reject(new StatementFetchError(locked ? `another Chrome took over the ${basename(profile)} profile as this one started; run the command again, or pass --force` : `${chrome} exited with ${code} before it was ready\n${stderr.trim()}`));
|
|
457
547
|
});
|
|
458
548
|
}).catch((err) => {
|
|
459
549
|
child.kill();
|
|
@@ -588,7 +678,7 @@ export const CLICK_RECORDER = `document.addEventListener('click', (event) => {
|
|
|
588
678
|
|
|
589
679
|
async function attachPage(cdp) {
|
|
590
680
|
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
591
|
-
let targetId = (targetInfos.find((t) => t.type === 'page') || {}).targetId;
|
|
681
|
+
let targetId = (targetInfos.find((t) => t.type === 'page' && !String(t.url || '').startsWith('chrome')) || {}).targetId;
|
|
592
682
|
if (!targetId) ({ targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }));
|
|
593
683
|
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
594
684
|
await cdp.send('Page.enable', {}, sessionId);
|
|
@@ -847,7 +937,7 @@ export async function keepStatement({ institution, download, state, home, import
|
|
|
847
937
|
* `api` carries `listAccounts()`, `importStatement(opts)` and `reportRun(run)`.
|
|
848
938
|
* Returns one summary per bank; never throws for one bank's failure.
|
|
849
939
|
*/
|
|
850
|
-
export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli' }) {
|
|
940
|
+
export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli', force = false }) {
|
|
851
941
|
const institutions = groupInstitutions(await api.listAccounts());
|
|
852
942
|
if (!institutions.length) throw new StatementFetchError('no linked accounts; connect a bank in CoinPay first');
|
|
853
943
|
const chosen = banks.length ? banks.map((name) => pickInstitution(institutions, name)) : institutions.filter((entry) => signedIn(entry.key, home));
|
|
@@ -868,7 +958,7 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
|
|
|
868
958
|
summary.status = 'login_needed';
|
|
869
959
|
summary.message = `not signed in: coinpay finances statements login ${institution.key}`;
|
|
870
960
|
} else {
|
|
871
|
-
browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless });
|
|
961
|
+
browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless, force });
|
|
872
962
|
const seen = new Set(state.entries.filter((entry) => entry.institution === institution.key && entry.key).map((entry) => entry.key));
|
|
873
963
|
const page = await fetchInstitution(browser, {
|
|
874
964
|
start, seen, since, max, renderMs, log,
|