@profullstack/coinpay 0.12.0 → 0.12.1
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 +9 -0
- package/bin/coinpay.js +1 -0
- package/package.json +1 -1
- package/src/finances-commands.js +2 -2
- package/src/statements-fetch.js +97 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ 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.12.1] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- `coinpay finances statements login|fetch|assist` no longer reports "profile is
|
|
12
|
+
open in another Chrome" after an interrupted run. A lock left by a process
|
|
13
|
+
that is gone, or now belongs to another program, is removed. Our own leftover
|
|
14
|
+
headless Chrome is stopped. A visible window is closed only with `--force`.
|
|
15
|
+
Ctrl+C or a crash now takes the Chrome it started down with it.
|
|
16
|
+
|
|
8
17
|
## [0.12.0] - 2026-10-04
|
|
9
18
|
|
|
10
19
|
### Added
|
package/bin/coinpay.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
package/src/finances-commands.js
CHANGED
|
@@ -201,7 +201,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
201
201
|
const state = sf.loadLocal(home);
|
|
202
202
|
const local = (state.institutions[institution.key] ||= {});
|
|
203
203
|
const urls = sf.startUrls(institution, local.start);
|
|
204
|
-
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false });
|
|
204
|
+
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false, force: flags.force === true });
|
|
205
205
|
if (action === 'login') {
|
|
206
206
|
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
207
207
|
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
@@ -239,7 +239,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
239
239
|
if (!Number.isInteger(max) || max < 1 || max > 500) throw new CliExit(EXIT.INVALID, '--max is a whole number from 1 to 500');
|
|
240
240
|
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
241
241
|
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 });
|
|
242
|
+
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
243
|
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
244
|
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
245
245
|
} catch (err) {
|
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 }) {
|
|
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 = [
|
|
@@ -439,6 +512,22 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
|
|
|
439
512
|
];
|
|
440
513
|
const child = spawn(chrome, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
441
514
|
const exited = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
515
|
+
// Never leave Chrome holding the profile when we go: an orphan is what
|
|
516
|
+
// makes the next run see the profile "open in another Chrome".
|
|
517
|
+
const onExit = () => { try { child.kill('SIGKILL'); } catch { /* gone */ } };
|
|
518
|
+
const onSignal = (signal) => {
|
|
519
|
+
onExit();
|
|
520
|
+
detach();
|
|
521
|
+
process.kill(process.pid, signal);
|
|
522
|
+
};
|
|
523
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
524
|
+
const detach = () => {
|
|
525
|
+
process.removeListener('exit', onExit);
|
|
526
|
+
for (const signal of signals) process.removeListener(signal, onSignal);
|
|
527
|
+
};
|
|
528
|
+
process.once('exit', onExit);
|
|
529
|
+
for (const signal of signals) process.once(signal, onSignal);
|
|
530
|
+
exited.then(detach);
|
|
442
531
|
const url = await new Promise((resolve, reject) => {
|
|
443
532
|
let stderr = '';
|
|
444
533
|
const timer = setTimeout(() => reject(new StatementFetchError(`${chrome} did not start within ${timeoutMs / 1000}s\n${stderr}`)), timeoutMs);
|
|
@@ -452,8 +541,8 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
|
|
|
452
541
|
});
|
|
453
542
|
child.on('exit', (code) => {
|
|
454
543
|
clearTimeout(timer);
|
|
455
|
-
const locked = /SingletonLock|ProcessSingleton
|
|
456
|
-
reject(new StatementFetchError(locked ? `the ${profile} profile
|
|
544
|
+
const locked = /SingletonLock|ProcessSingleton|existing browser session/i.test(stderr);
|
|
545
|
+
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
546
|
});
|
|
458
547
|
}).catch((err) => {
|
|
459
548
|
child.kill();
|
|
@@ -847,7 +936,7 @@ export async function keepStatement({ institution, download, state, home, import
|
|
|
847
936
|
* `api` carries `listAccounts()`, `importStatement(opts)` and `reportRun(run)`.
|
|
848
937
|
* Returns one summary per bank; never throws for one bank's failure.
|
|
849
938
|
*/
|
|
850
|
-
export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli' }) {
|
|
939
|
+
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
940
|
const institutions = groupInstitutions(await api.listAccounts());
|
|
852
941
|
if (!institutions.length) throw new StatementFetchError('no linked accounts; connect a bank in CoinPay first');
|
|
853
942
|
const chosen = banks.length ? banks.map((name) => pickInstitution(institutions, name)) : institutions.filter((entry) => signedIn(entry.key, home));
|
|
@@ -868,7 +957,7 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
|
|
|
868
957
|
summary.status = 'login_needed';
|
|
869
958
|
summary.message = `not signed in: coinpay finances statements login ${institution.key}`;
|
|
870
959
|
} else {
|
|
871
|
-
browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless });
|
|
960
|
+
browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless, force });
|
|
872
961
|
const seen = new Set(state.entries.filter((entry) => entry.institution === institution.key && entry.key).map((entry) => entry.key));
|
|
873
962
|
const page = await fetchInstitution(browser, {
|
|
874
963
|
start, seen, since, max, renderMs, log,
|