@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 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
@@ -165,6 +165,7 @@ const BOOLEAN_FLAGS = new Set([
165
165
  'wait',
166
166
  'strict',
167
167
  'headed',
168
+ 'force',
168
169
  'setup-token-stdin',
169
170
  'overwrite',
170
171
  'no-pending',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.12.0",
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",
@@ -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) {
@@ -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
- export async function openBrowser({ chrome, profile, headless = true, timeoutMs = 30_000, env = process.env }) {
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/.test(stderr);
456
- reject(new StatementFetchError(locked ? `the ${profile} profile is open in another Chrome; close that window first` : `${chrome} exited with ${code} before it was ready\n${stderr.trim()}`));
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,