@profullstack/coinpay 0.6.11 → 0.8.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/bin/coinpay.js CHANGED
@@ -15,16 +15,34 @@ import {
15
15
  DEFAULT_CHAINS,
16
16
  } from '../src/wallet.js';
17
17
  import { SwapClient, SwapCoins } from '../src/swap.js';
18
+ import { createInvoice, getInvoice, listInvoices, InvoiceStatus } from '../src/invoices.js';
18
19
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
19
- import { execSync } from 'child_process';
20
+ import { execSync, spawn } from 'child_process';
20
21
  import { createInterface } from 'readline';
21
- import { homedir } from 'os';
22
+ import { homedir, hostname } from 'os';
22
23
  import { join } from 'path';
23
24
  import { tmpdir } from 'os';
24
25
 
25
26
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
- const CONFIG_FILE = join(homedir(), '.coinpay.json');
27
- const DEFAULT_WALLET_FILE = join(homedir(), '.coinpay-wallet.gpg');
27
+ const HOME_DIRECTORY = process.env.COINPAY_HOME || process.env.HOME || homedir();
28
+ const CONFIG_FILE = join(HOME_DIRECTORY, '.coinpay.json');
29
+ const DEFAULT_WALLET_FILE = join(HOME_DIRECTORY, '.coinpay-wallet.gpg');
30
+ // Where `coinpay update|upgrade|remove|uninstall` re-invoke the installer.
31
+ const INSTALL_URL = process.env.COINPAY_INSTALL_URL || 'https://coinpayportal.com/install.sh';
32
+
33
+ // Self-manage the CLI by delegating to the installer, which owns the
34
+ // fetch-from-GitHub / uninstall logic. Works whether the user hit the
35
+ // shell wrapper (which also intercepts these) or the raw binary directly.
36
+ function handleSelfManage(action) {
37
+ const label = action === 'remove' ? 'Removing' : 'Updating';
38
+ print.info(`${label} the coinpay CLI via ${INSTALL_URL}`);
39
+ try {
40
+ execSync(`curl -fsSL "${INSTALL_URL}" | sh -s -- ${action}`, { stdio: 'inherit' });
41
+ } catch {
42
+ print.error(`coinpay ${action} failed — run it manually:\n curl -fsSL ${INSTALL_URL} | sh -s -- ${action}`);
43
+ process.exit(1);
44
+ }
45
+ }
28
46
 
29
47
  // ANSI colors
30
48
  const colors = {
@@ -74,11 +92,11 @@ function saveConfig(config) {
74
92
  */
75
93
  function getWalletFilePath(flags = {}) {
76
94
  if (flags['wallet-file']) {
77
- return flags['wallet-file'].replace(/^~/, homedir());
95
+ return flags['wallet-file'].replace(/^~/, HOME_DIRECTORY);
78
96
  }
79
97
  const config = loadConfig();
80
98
  if (config.walletFile) {
81
- return config.walletFile.replace(/^~/, homedir());
99
+ return config.walletFile.replace(/^~/, HOME_DIRECTORY);
82
100
  }
83
101
  return DEFAULT_WALLET_FILE;
84
102
  }
@@ -107,13 +125,19 @@ function getBaseUrl() {
107
125
  return process.env.COINPAY_BASE_URL || config.baseUrl || 'https://coinpayportal.com/api';
108
126
  }
109
127
 
128
+ function getInvoiceShareUrl(invoiceId) {
129
+ return new URL(`/now/${encodeURIComponent(invoiceId)}`, getBaseUrl()).toString();
130
+ }
131
+
110
132
  /**
111
133
  * Create client instance
112
134
  */
113
135
  function createClient() {
114
- const apiKey = getApiKey();
136
+ // An API key, or the session token from `coinpay login` (the merchant JWT is
137
+ // accepted by the API the same way — via its userId claim).
138
+ const apiKey = getApiKey() || loadConfig().jwtToken;
115
139
  if (!apiKey) {
116
- print.error('API key not configured. Run: coinpay config set-key <api-key>');
140
+ print.error('Not authenticated. Run: coinpay login (or coinpay config set-key <api-key>)');
117
141
  process.exit(1);
118
142
  }
119
143
  return new CoinPayClient({ apiKey, baseUrl: getBaseUrl() });
@@ -162,6 +186,52 @@ function parseArgs(args) {
162
186
  return result;
163
187
  }
164
188
 
189
+ function parsePositiveInteger(value) {
190
+ if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
191
+ return null;
192
+ }
193
+
194
+ const parsed = Number(value);
195
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
196
+ }
197
+
198
+ function parsePositiveAmount(value) {
199
+ if (typeof value !== 'string') return null;
200
+
201
+ const normalized = value.trim();
202
+ const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(normalized);
203
+ if (!match) return null;
204
+
205
+ const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'));
206
+ if (cents <= 0n || cents > BigInt(Number.MAX_SAFE_INTEGER)) return null;
207
+
208
+ return Number(cents) / 100;
209
+ }
210
+
211
+ function isValidDate(value) {
212
+ if (typeof value !== 'string') return false;
213
+
214
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
215
+ if (!match) return false;
216
+
217
+ const [, year, month, day] = match.map(Number);
218
+ const date = new Date(Date.UTC(year, month - 1, day));
219
+ return (
220
+ date.getUTCFullYear() === year &&
221
+ date.getUTCMonth() === month - 1 &&
222
+ date.getUTCDate() === day
223
+ );
224
+ }
225
+
226
+ function requireFlagValues(flags, names) {
227
+ for (const name of names) {
228
+ if (flags[name] === true) {
229
+ print.error('--' + name + ' requires a value');
230
+ process.exit(1);
231
+ }
232
+ }
233
+ }
234
+
165
235
  /**
166
236
  * Show help
167
237
  */
@@ -189,6 +259,17 @@ ${colors.cyan}Commands:${colors.reset}
189
259
  list List payments
190
260
  qr <id> Get payment QR code
191
261
 
262
+ ${colors.bright}invoice${colors.reset}
263
+ create Create a draft invoice (--amount required)
264
+ --wallet-id <id> Route settlement through a saved wallet
265
+ --merchant-wallet-address <address>
266
+ Route settlement to a direct address
267
+ get <id> Get invoice details
268
+ list List invoices with optional filters
269
+
270
+ ${colors.bright}tokens${colors.reset}
271
+ list List checkout tokens (--business-id, --active-only)
272
+
192
273
  ${colors.bright}business${colors.reset}
193
274
  create Create a new business
194
275
  get <id> Get business details
@@ -229,7 +310,7 @@ ${colors.cyan}Commands:${colors.reset}
229
310
  create Create a card payment via Stripe
230
311
  get <id> Get card payment status
231
312
  list List card payments
232
- connect onboard <id> Create Stripe onboarding link
313
+ connect onboard <id> Create Stripe onboarding link (--country required first time)
233
314
  connect status <id> Check Stripe account status
234
315
  escrow release <id> Release card escrow funds
235
316
  escrow refund <id> Refund card escrow
@@ -259,6 +340,7 @@ ${colors.cyan}Commands:${colors.reset}
259
340
  badge [did] Get embeddable reputation badge URL
260
341
  verify <id> Verify a credential
261
342
  revocations List revoked credentials
343
+ did setup Use existing DID or create one automatically
262
344
  did claim Claim (auto-generate) a new DID
263
345
  did me Show your current DID
264
346
  issuer register Register platform issuer (--name, --domain)
@@ -270,6 +352,12 @@ ${colors.cyan}Commands:${colors.reset}
270
352
  logs <business-id> Get webhook logs
271
353
  test <business-id> Send test webhook
272
354
 
355
+ ${colors.bright}self${colors.reset}
356
+ update Update the coinpay CLI to the latest version
357
+ upgrade Alias for update
358
+ remove Uninstall the coinpay CLI
359
+ uninstall Alias for remove
360
+
273
361
  ${colors.cyan}Wallet Options:${colors.reset}
274
362
  --words <12|24> Number of mnemonic words (default: 12)
275
363
  --chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
@@ -326,6 +414,12 @@ ${colors.cyan}Examples:${colors.reset}
326
414
  coinpay swap quote --from BTC --to ETH --amount 0.1
327
415
  coinpay swap create --from BTC --to ETH --amount 0.1 --settle 0x...
328
416
 
417
+ # Connect Stripe to a business (country is locked at Stripe account creation)
418
+ coinpay card connect onboard <business-id> --country CA --email merchant@example.com
419
+
420
+ # List payment tokens for a business
421
+ coinpay tokens list --business-id <business-id> --active-only
422
+
329
423
  ${colors.cyan}Environment Variables:${colors.reset}
330
424
  COINPAY_API_KEY API key (overrides config)
331
425
  COINPAY_BASE_URL Custom API URL
@@ -388,6 +482,51 @@ async function promptYesNo(question, defaultYes = true) {
388
482
  return answer.toLowerCase().startsWith('y');
389
483
  }
390
484
 
485
+ /**
486
+ * Run gpg with the passphrase on a dedicated file descriptor and the plaintext
487
+ * on stdin, so neither ever touches the filesystem.
488
+ *
489
+ * This replaces a pair of temporary files in the system temp directory that
490
+ * held the wallet MNEMONIC and the password in plaintext for the duration of
491
+ * the gpg call. Three things made that dangerous:
492
+ *
493
+ * 1. The names were predictable — built from Date.now() — so another process
494
+ * could poll the temp directory for `coinpay-wallet-*`.
495
+ * 2. Mode 0600 keeps out other OS users. It does not keep out anything
496
+ * running AS the same user, which is the realistic threat on a developer
497
+ * machine: a malicious npm postinstall, a compromised editor extension.
498
+ * 3. Overwrite-then-unlink does not reliably erase data on journalling or
499
+ * copy-on-write filesystems, or on SSDs that remap blocks.
500
+ *
501
+ * The mnemonic derives every private key on every chain, so this window was
502
+ * worth removing rather than narrowing. Using fd 3 also keeps the passphrase
503
+ * off the command line, where it would be visible in `ps`.
504
+ */
505
+ function runGpg(args, { stdinData, passphrase }) {
506
+ return new Promise((resolve, reject) => {
507
+ const child = spawn('gpg', args, { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] });
508
+
509
+ const stdout = [];
510
+ let stderr = '';
511
+
512
+ child.stdout.on('data', (d) => stdout.push(d));
513
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
514
+ child.on('error', reject);
515
+ child.on('close', (code) => {
516
+ if (code === 0) resolve(Buffer.concat(stdout));
517
+ else reject(new Error(stderr.trim() || `gpg exited with code ${code}`));
518
+ });
519
+
520
+ const passFd = child.stdio[3];
521
+ passFd.on('error', () => {});
522
+ passFd.end(passphrase);
523
+
524
+ child.stdin.on('error', () => {});
525
+ if (stdinData !== undefined) child.stdin.end(stdinData);
526
+ else child.stdin.end();
527
+ });
528
+ }
529
+
391
530
  /**
392
531
  * Check if gpg is available
393
532
  */
@@ -415,24 +554,22 @@ async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
415
554
  createdAt: new Date().toISOString(),
416
555
  });
417
556
 
418
- const tmpFile = join(tmpdir(), `coinpay-wallet-${Date.now()}.json`);
419
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
420
-
421
- try {
422
- writeFileSync(tmpFile, content, { mode: 0o600 });
423
- writeFileSync(passFile, password, { mode: 0o600 });
424
-
425
- execSync(
426
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --symmetric --cipher-algo AES256 --output "${walletFile}" "${tmpFile}"`,
427
- { stdio: ['pipe', 'pipe', 'pipe'] }
428
- );
429
-
430
- return true;
431
- } finally {
432
- // Secure cleanup
433
- try { writeFileSync(tmpFile, Buffer.alloc(content.length, 0)); unlinkSync(tmpFile); } catch {}
434
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
435
- }
557
+ // The mnemonic goes to gpg on stdin and the passphrase on fd 3. Neither is
558
+ // written to disk, and neither appears in the process arguments.
559
+ await runGpg(
560
+ [
561
+ '--batch',
562
+ '--yes',
563
+ '--passphrase-fd', '3',
564
+ '--pinentry-mode', 'loopback',
565
+ '--symmetric',
566
+ '--cipher-algo', 'AES256',
567
+ '--output', walletFile,
568
+ ],
569
+ { stdinData: content, passphrase: password }
570
+ );
571
+
572
+ return true;
436
573
  }
437
574
 
438
575
  /**
@@ -447,21 +584,18 @@ async function loadEncryptedWallet(password, walletFile) {
447
584
  throw new Error(`Wallet file not found: ${walletFile}`);
448
585
  }
449
586
 
450
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
451
-
452
- try {
453
- writeFileSync(passFile, password, { mode: 0o600 });
454
-
455
- const result = execSync(
456
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --decrypt "${walletFile}"`,
457
- { stdio: ['pipe', 'pipe', 'pipe'] }
458
- );
459
-
460
- const data = JSON.parse(result.toString());
461
- return data;
462
- } finally {
463
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
464
- }
587
+ const result = await runGpg(
588
+ [
589
+ '--batch',
590
+ '--yes',
591
+ '--passphrase-fd', '3',
592
+ '--pinentry-mode', 'loopback',
593
+ '--decrypt', walletFile,
594
+ ],
595
+ { passphrase: password }
596
+ );
597
+
598
+ return JSON.parse(result.toString());
465
599
  }
466
600
 
467
601
  /**
@@ -637,6 +771,200 @@ async function handlePayment(subcommand, args, flags) {
637
771
  }
638
772
  }
639
773
 
774
+ function printInvoiceDetails(invoice, { shareUrl } = {}) {
775
+ const label = invoice.invoiceNumber || invoice.id;
776
+ console.log('\n' + colors.bright + label + colors.reset);
777
+ print.info('ID: ' + invoice.id);
778
+ print.info('Status: ' + (invoice.status || 'unknown'));
779
+ print.info('Amount: ' + invoice.amount + ' ' + (invoice.currency || ''));
780
+ if (invoice.businessId) print.info('Business: ' + invoice.businessId);
781
+ if (invoice.clientId) print.info('Client: ' + invoice.clientId);
782
+ if (invoice.cryptoCurrency) print.info('Crypto: ' + invoice.cryptoCurrency);
783
+ if (invoice.dueDate) print.info('Due: ' + invoice.dueDate);
784
+ if (invoice.notes) print.info('Notes: ' + invoice.notes);
785
+ if (shareUrl) print.info('Share link (opens after send): ' + shareUrl);
786
+ console.log();
787
+ }
788
+
789
+ /**
790
+ * Invoice commands
791
+ */
792
+ async function handleInvoice(subcommand, args, flags) {
793
+ const client = createClient();
794
+
795
+ switch (subcommand) {
796
+ case 'create': {
797
+ requireFlagValues(flags, [
798
+ 'amount',
799
+ 'business-id',
800
+ 'client-id',
801
+ 'currency',
802
+ 'crypto-currency',
803
+ 'due-date',
804
+ 'notes',
805
+ 'wallet-id',
806
+ 'merchant-wallet-address',
807
+ ]);
808
+
809
+ const amount = parsePositiveAmount(flags.amount);
810
+ if (amount === null) {
811
+ print.error('--amount must be a positive decimal with at most two decimal places');
812
+ print.info('Example: coinpay invoice create --amount 125 --currency USD --notes "Consulting"');
813
+ process.exit(1);
814
+ }
815
+
816
+ const currency = flags.currency || 'USD';
817
+ if (!/^[A-Za-z]{3}$/.test(currency)) {
818
+ print.error('--currency must be a three-letter currency code');
819
+ process.exit(1);
820
+ }
821
+
822
+ const dueDate = flags['due-date'];
823
+ if (dueDate !== undefined && !isValidDate(dueDate)) {
824
+ print.error('--due-date must be a valid date in YYYY-MM-DD format');
825
+ process.exit(1);
826
+ }
827
+
828
+ const cryptoCurrency = flags['crypto-currency'];
829
+ const invoice = await createInvoice(client, {
830
+ businessId: flags['business-id'],
831
+ clientId: flags['client-id'],
832
+ currency: currency.toUpperCase(),
833
+ amount,
834
+ cryptoCurrency: cryptoCurrency?.toUpperCase(),
835
+ dueDate,
836
+ notes: flags.notes,
837
+ walletId: flags['wallet-id'],
838
+ merchantWalletAddress: flags['merchant-wallet-address'],
839
+ });
840
+ const shareUrl = invoice.id ? getInvoiceShareUrl(invoice.id) : undefined;
841
+
842
+ if (flags.json) {
843
+ print.json(shareUrl ? { ...invoice, shareUrl } : invoice);
844
+ break;
845
+ }
846
+
847
+ print.success('Draft invoice created');
848
+ printInvoiceDetails(invoice, { shareUrl });
849
+ break;
850
+ }
851
+
852
+ case 'list': {
853
+ requireFlagValues(flags, ['business-id', 'status', 'client-id', 'date-from', 'date-to']);
854
+
855
+ const status = flags.status?.toLowerCase();
856
+ const validStatuses = new Set(Object.values(InvoiceStatus));
857
+ if (status !== undefined && !validStatuses.has(status)) {
858
+ print.error('--status must be one of: ' + [...validStatuses].join(', '));
859
+ process.exit(1);
860
+ }
861
+
862
+ const dateFrom = flags['date-from'];
863
+ const dateTo = flags['date-to'];
864
+ if (dateFrom !== undefined && !isValidDate(dateFrom)) {
865
+ print.error('--date-from must be a valid date in YYYY-MM-DD format');
866
+ process.exit(1);
867
+ }
868
+ if (dateTo !== undefined && !isValidDate(dateTo)) {
869
+ print.error('--date-to must be a valid date in YYYY-MM-DD format');
870
+ process.exit(1);
871
+ }
872
+ if (dateFrom && dateTo && dateFrom > dateTo) {
873
+ print.error('--date-from must not be later than --date-to');
874
+ process.exit(1);
875
+ }
876
+
877
+ const result = await listInvoices(client, {
878
+ businessId: flags['business-id'],
879
+ status,
880
+ clientId: flags['client-id'],
881
+ dateFrom,
882
+ dateTo,
883
+ });
884
+
885
+ if (flags.json) {
886
+ print.json(result);
887
+ break;
888
+ }
889
+
890
+ if (result.invoices.length === 0) {
891
+ print.info('No invoices found');
892
+ break;
893
+ }
894
+
895
+ console.log('\n' + colors.bright + 'Invoices' + colors.reset + ' (' + (result.total ?? result.invoices.length) + ')\n');
896
+ for (const invoice of result.invoices) {
897
+ const label = invoice.invoiceNumber || invoice.id;
898
+ const created = invoice.createdAt ? invoice.createdAt.slice(0, 10) : '-';
899
+ console.log(
900
+ ' ' + colors.bright + label + colors.reset +
901
+ ' ' + (invoice.status || 'unknown') +
902
+ ' ' + invoice.amount + ' ' + (invoice.currency || '') +
903
+ ' ' + created
904
+ );
905
+ if (invoice.invoiceNumber && invoice.id) console.log(' ' + invoice.id);
906
+ }
907
+ console.log();
908
+ break;
909
+ }
910
+
911
+ case 'get': {
912
+ const id = args[0];
913
+ if (!id) {
914
+ print.error('Usage: coinpay invoice get <id>');
915
+ process.exit(1);
916
+ }
917
+
918
+ const invoice = await getInvoice(client, id);
919
+ if (flags.json) {
920
+ print.json(invoice);
921
+ break;
922
+ }
923
+
924
+ printInvoiceDetails(invoice);
925
+ break;
926
+ }
927
+
928
+ default:
929
+ print.error('Unknown invoice command: ' + subcommand);
930
+ print.info('Available: create, list, get');
931
+ process.exit(1);
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Token discovery commands
937
+ */
938
+ async function handleTokens(subcommand, args, flags) {
939
+ const client = createClient();
940
+
941
+ switch (subcommand) {
942
+ case 'list': {
943
+ const businessId = flags['business-id'];
944
+ const activeOnly = flags['active-only'] === true || flags.active === true;
945
+ const result = await client.getTokens({ businessId, activeOnly });
946
+
947
+ if (flags.json) {
948
+ print.json(result);
949
+ break;
950
+ }
951
+
952
+ const tokens = result.tokens || [];
953
+ print.info(`Payment tokens (${tokens.length})`);
954
+ for (const token of tokens) {
955
+ const chain = token.chain ? ` on ${token.chain}` : '';
956
+ const source = token.wallet_source ? ` (${token.wallet_source})` : '';
957
+ console.log(` ${colors.bright}${token.code}${colors.reset} ${token.ticker || token.symbol}${chain}${source}`);
958
+ }
959
+ break;
960
+ }
961
+
962
+ default:
963
+ print.error(`Unknown tokens command: ${subcommand}`);
964
+ print.info('Available: list');
965
+ }
966
+ }
967
+
640
968
  /**
641
969
  * Business commands
642
970
  */
@@ -1836,17 +2164,18 @@ async function handleAuth(subcommand, args, flags) {
1836
2164
  const client = new CoinPayClient({ apiKey: jwtToken, baseUrl: getBaseUrl() });
1837
2165
  const { getMe } = await import('../src/auth.js');
1838
2166
  const result = await getMe(client);
1839
-
2167
+ const me = result?.merchant || result;
2168
+
1840
2169
  print.success('Current merchant:');
1841
- print.info(`ID: ${result.id}`);
1842
- print.info(`Email: ${result.email}`);
1843
- if (result.name) {
1844
- print.info(`Name: ${result.name}`);
2170
+ print.info(`ID: ${me.id}`);
2171
+ print.info(`Email: ${me.email}`);
2172
+ if (me.name) {
2173
+ print.info(`Name: ${me.name}`);
1845
2174
  }
1846
- print.info(`Created: ${result.created_at}`);
1847
-
2175
+ if (me.created_at) print.info(`Created: ${me.created_at}`);
2176
+
1848
2177
  if (flags.json) {
1849
- print.json(result);
2178
+ print.json(me);
1850
2179
  }
1851
2180
  } catch (error) {
1852
2181
  if (error.message.includes('401') || error.message.includes('403')) {
@@ -2013,15 +2342,28 @@ async function handleReputation(subcommand, args, flags) {
2013
2342
  const client = createClient();
2014
2343
 
2015
2344
  if (didSubcommand === 'claim') {
2016
- const { claimDid } = await import('../src/reputation.js');
2017
- const result = await claimDid(client);
2345
+ const { claimDid, getMyDid } = await import('../src/reputation.js');
2346
+
2347
+ // Idempotent: a merchant can only hold one principal DID, so if one
2348
+ // already exists just show it (exit 0) instead of erroring with
2349
+ // "already has a principal DID". This lets automated setup (e.g.
2350
+ // c0mpute worker onboarding) call `did claim` unconditionally.
2351
+ let existing = null;
2352
+ try {
2353
+ existing = await getMyDid(client);
2354
+ } catch {
2355
+ existing = null; // no DID yet — fall through to claim
2356
+ }
2357
+
2358
+ const result = existing?.did ? existing : await claimDid(client);
2018
2359
 
2019
2360
  if (result.did) {
2020
- print.success(`DID claimed: ${result.did}`);
2361
+ print.success(`${existing?.did ? 'DID ready' : 'DID claimed'}: ${result.did}`);
2021
2362
  console.log(` Public Key: ${result.public_key}`);
2022
2363
  console.log(` Verified: ${result.verified}`);
2023
2364
  } else {
2024
2365
  print.error(result.error || 'Failed to claim DID');
2366
+ process.exitCode = 1;
2025
2367
  }
2026
2368
 
2027
2369
  if (flags.json) print.json(result);
@@ -2061,6 +2403,31 @@ async function handleReputation(subcommand, args, flags) {
2061
2403
  print.info('No DID found. Run: coinpay reputation did claim');
2062
2404
  }
2063
2405
 
2406
+ if (flags.json) print.json(result);
2407
+ } else if (didSubcommand === 'setup') {
2408
+ // Ensure the merchant has a payable DID, hands-free: use the existing
2409
+ // one if there is one, otherwise create it automatically. No prompts —
2410
+ // worker onboarding (c0mpute worker register) must never block.
2411
+ const { getMyDid, claimDid } = await import('../src/reputation.js');
2412
+
2413
+ let existing = null;
2414
+ try {
2415
+ existing = await getMyDid(client);
2416
+ } catch {
2417
+ existing = null; // none yet — create below
2418
+ }
2419
+
2420
+ const result = existing?.did ? existing : await claimDid(client);
2421
+
2422
+ if (result.did) {
2423
+ print.success(`${existing?.did ? 'Using existing DID' : 'Created DID'}: ${result.did}`);
2424
+ console.log(` Public Key: ${result.public_key}`);
2425
+ console.log(` Verified: ${result.verified}`);
2426
+ } else {
2427
+ print.error(result.error || 'Failed to set up DID');
2428
+ process.exitCode = 1;
2429
+ }
2430
+
2064
2431
  if (flags.json) print.json(result);
2065
2432
  } else {
2066
2433
  // Default: show current DID
@@ -2291,9 +2658,9 @@ async function handlePayout(subcommand, args, flags) {
2291
2658
 
2292
2659
  switch (subcommand) {
2293
2660
  case 'create': {
2294
- const amount = parseInt(flags.amount);
2295
- if (!amount || amount <= 0) {
2296
- print.error('--amount is required (in cents, e.g., 5000 for $50.00)');
2661
+ const amount = parsePositiveInteger(flags.amount);
2662
+ if (amount === null) {
2663
+ print.error('--amount must be a positive integer in cents (e.g., 5000 for $50.00)');
2297
2664
  print.info('Example: coinpay payout create --amount 5000 --description "Weekly payout"');
2298
2665
  process.exit(1);
2299
2666
  }
@@ -2435,7 +2802,8 @@ async function handleCard(subcommand, args, flags) {
2435
2802
  case 'onboard': {
2436
2803
  if (!merchantId) {
2437
2804
  print.error('Merchant ID required');
2438
- print.info('Usage: coinpay card connect onboard <merchantId>');
2805
+ print.info('Usage: coinpay card connect onboard <merchantId> --country <ISO-2> [--email <email>]');
2806
+ print.info('Country is required on first onboarding (e.g. US, CA, GB, AU, DE) and is locked once the Stripe account is created.');
2439
2807
  return;
2440
2808
  }
2441
2809
 
@@ -2559,6 +2927,374 @@ async function handleCard(subcommand, args, flags) {
2559
2927
  /**
2560
2928
  * Main entry point
2561
2929
  */
2930
+ // Top-level commands offered by the interactive menu (shown when `coinpay` is
2931
+ // run with no arguments in a TTY). Self-manage commands are intentionally omitted.
2932
+ const MENU_COMMANDS = [
2933
+ ['login', 'Log in headlessly — approve in a browser on any device'],
2934
+ ['whoami', 'Show the signed-in merchant'],
2935
+ ['logout', 'Sign out on this device'],
2936
+ ['config', 'API key & endpoint (set-key, set-url, show)'],
2937
+ ['auth', 'Merchant account (register, login, me)'],
2938
+ ['payment', 'Payments (create, get, list, qr)'],
2939
+ ['tokens', 'List checkout tokens'],
2940
+ ['business', 'Businesses (create, get, list, update)'],
2941
+ ['rates', 'Exchange rates (get, list)'],
2942
+ ['wallet', 'Local encrypted wallet (create, import, balance, send, history…)'],
2943
+ ['swap', 'Cross-chain swaps'],
2944
+ ['escrow', 'Escrow & series operations'],
2945
+ ['ln', 'Lightning wallet'],
2946
+ ['webhook', 'Webhook management'],
2947
+ ['payout', 'Payouts'],
2948
+ ['card', 'Card payments & issuing'],
2949
+ ['reputation', 'DID, credentials, receipts, issuer'],
2950
+ ['x402', 'x402 protocol operations'],
2951
+ ['oauth', 'OAuth operations'],
2952
+ ];
2953
+
2954
+ // Subcommands per command group (name → [[subcommand, description], …]).
2955
+ const SUBCOMMANDS = {
2956
+ config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
2957
+ auth: [['register', 'Register new merchant account'], ['login', 'Login to merchant account'], ['me', 'Show current merchant info']],
2958
+ payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
2959
+ tokens: [['list', 'List checkout tokens']],
2960
+ business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>']],
2961
+ rates: [['get', 'Get exchange rate <crypto>'], ['list', 'Get all exchange rates']],
2962
+ wallet: [['create', 'Create a new wallet'], ['import', 'Import wallet <mnemonic>'], ['unlock', 'Decrypt wallet and show info'], ['info', 'Show wallet info'], ['addresses', 'List all addresses'], ['derive', 'Derive new address <chain>'], ['derive-missing', 'Derive missing chains'], ['balance', 'Get balance(s) [chain]'], ['send', 'Send a transaction'], ['history', 'Transaction history'], ['backup', 'Export encrypted backup'], ['delete', 'Delete local wallet file']],
2963
+ swap: [['coins', 'List supported coins'], ['quote', 'Get swap quote'], ['create', 'Create swap transaction'], ['status', 'Check swap status <swap-id>'], ['history', 'Swap history']],
2964
+ escrow: [['create', 'Create a new escrow'], ['get', 'Get escrow status <id>'], ['list', 'List escrows'], ['release', 'Release funds <id>'], ['refund', 'Refund funds <id>'], ['dispute', 'Open a dispute <id>'], ['events', 'Get escrow audit log <id>'], ['auth', 'Authenticate with escrow token <id>'], ['series', 'Series operations']],
2965
+ ln: [['enable', 'Enable Lightning wallet'], ['address', 'Lightning address'], ['invoice', 'Create an invoice'], ['send', 'Send a payment'], ['balance', 'Lightning balance'], ['payments', 'List payments']],
2966
+ webhook: [['logs', 'Get webhook logs <business-id>'], ['test', 'Send test webhook <business-id>']],
2967
+ payout: [['create', 'Create a payout'], ['list', 'List payouts'], ['get', 'Get payout details <id>']],
2968
+ card: [['create', 'Create a card payment'], ['get', 'Get card payment status <id>'], ['list', 'List card payments'], ['connect', 'Stripe onboarding / status'], ['escrow', 'Release / refund card escrow']],
2969
+ reputation: [['submit', 'Submit a task receipt'], ['query', 'Query agent reputation <did>'], ['credential', 'Get credential details <id>'], ['credentials', 'List credentials [did]'], ['receipts', 'List task receipts [did]'], ['badge', 'Reputation badge URL [did]'], ['verify', 'Verify a credential <id>'], ['revocations', 'List revoked credentials'], ['did', 'DID claim / me'], ['issuer', 'Issuer register / list / rotate']],
2970
+ x402: [
2971
+ ['status', 'x402 status'],
2972
+ ['test', 'x402 test'],
2973
+ ['pay', 'pay for a URL, approving in your browser'],
2974
+ ],
2975
+ oauth: [['list', 'List OAuth clients'], ['create', 'Create an OAuth client'], ['get', 'Get client details <id>'], ['delete', 'Delete a client <id>']],
2976
+ };
2977
+
2978
+ function askLine(prompt) {
2979
+ return new Promise((resolve) => {
2980
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2981
+ rl.question(prompt, (answer) => { rl.close(); resolve(answer); });
2982
+ });
2983
+ }
2984
+
2985
+ // Re-invoke this CLI as a child so the real parser/handlers run unchanged.
2986
+ function runChild(argv) {
2987
+ return new Promise((resolve) => {
2988
+ const child = spawn(process.execPath, [process.argv[1], ...argv], { stdio: 'inherit' });
2989
+ child.on('exit', () => resolve());
2990
+ child.on('error', () => resolve());
2991
+ });
2992
+ }
2993
+
2994
+ // An authenticated client (session token or API key) for the guided menu flows
2995
+ // that need to fetch your data. Returns null if not authenticated.
2996
+ function authedClientOrNull() {
2997
+ const apiKey = getApiKey() || loadConfig().jwtToken;
2998
+ return apiKey ? new CoinPayClient({ apiKey, baseUrl: getBaseUrl() }) : null;
2999
+ }
3000
+
3001
+ function unwrapList(res) {
3002
+ const list = res?.data ?? res?.businesses ?? res?.payments ?? res;
3003
+ return Array.isArray(list) ? list : [];
3004
+ }
3005
+
3006
+ // Fetch the merchant's businesses and let them pick one; returns a business id.
3007
+ async function pickBusiness(io) {
3008
+ const client = authedClientOrNull();
3009
+ if (!client) { print.error('Not signed in — run `coinpay login` first.'); return null; }
3010
+ let list;
3011
+ try { list = unwrapList(await client.listBusinesses()); }
3012
+ catch (e) { print.error('Could not fetch businesses: ' + e.message); return null; }
3013
+ if (!list.length) { print.info('No businesses yet — create one first (business → create).'); return null; }
3014
+ if (list.length === 1) return list[0].id; // only one — no need to ask
3015
+ return io.select({
3016
+ message: 'Which business?',
3017
+ choices: list.map((b) => ({ name: `${b.name || b.id} ${colors.reset}(${b.id})`, value: b.id })),
3018
+ pageSize: 15,
3019
+ });
3020
+ }
3021
+
3022
+ // Fetch payments for a business and let them pick one; returns a payment id.
3023
+ async function pickPayment(io, businessId) {
3024
+ const client = authedClientOrNull();
3025
+ if (!client) return null;
3026
+ let list;
3027
+ try { list = unwrapList(await client.listPayments({ businessId })); }
3028
+ catch (e) { print.error('Could not fetch payments: ' + e.message); return null; }
3029
+ if (!list.length) { print.info('No payments for that business yet.'); return null; }
3030
+ return io.select({
3031
+ message: 'Which payment?',
3032
+ choices: list.map((p) => ({
3033
+ name: `${p.id} ${[p.amount, p.currency, p.status].filter(Boolean).join(' ')}`.trim(),
3034
+ value: p.id,
3035
+ })),
3036
+ pageSize: 15,
3037
+ });
3038
+ }
3039
+
3040
+ // Guided interactive flows keyed by "command subcommand": fetch your data + ask
3041
+ // for inputs, then return the argv to run (or null to abort). Used by the menu.
3042
+ const GUIDED = {
3043
+ 'business get': async (io) => {
3044
+ const id = await pickBusiness(io);
3045
+ return id ? ['business', 'get', id] : null;
3046
+ },
3047
+ 'business update': async (io) => {
3048
+ const id = await pickBusiness(io);
3049
+ if (!id) return null;
3050
+ const name = (await io.input({ message: 'New name (blank to keep):' })).trim();
3051
+ const url = (await io.input({ message: 'New webhook URL (blank to keep):' })).trim();
3052
+ const argv = ['business', 'update', id];
3053
+ if (name) argv.push('--name', name);
3054
+ if (url) argv.push('--webhook-url', url);
3055
+ if (argv.length === 3) { print.info('Nothing to update.'); return null; }
3056
+ return argv;
3057
+ },
3058
+ 'business create': async (io) => {
3059
+ const name = (await io.input({ message: 'Business name:' })).trim();
3060
+ if (!name) { print.error('Name is required.'); return null; }
3061
+ const url = (await io.input({ message: 'Webhook URL (optional):' })).trim();
3062
+ const argv = ['business', 'create', '--name', name];
3063
+ if (url) argv.push('--webhook-url', url);
3064
+ return argv;
3065
+ },
3066
+ 'payment create': async (io) => {
3067
+ const businessId = await pickBusiness(io);
3068
+ if (!businessId) return null;
3069
+ const amount = (await io.input({ message: 'Amount:' })).trim();
3070
+ if (!amount) { print.error('Amount is required.'); return null; }
3071
+ const currency = (await io.input({ message: 'Currency:', default: 'USD' })).trim() || 'USD';
3072
+ const blockchain = await io.select({
3073
+ message: 'Blockchain:',
3074
+ choices: ['BTC', 'ETH', 'SOL', 'POL', 'BCH'].map((c) => ({ name: c, value: c })),
3075
+ });
3076
+ const description = (await io.input({ message: 'Description (optional):' })).trim();
3077
+ const argv = ['payment', 'create', '--business-id', businessId, '--amount', amount, '--currency', currency, '--blockchain', blockchain];
3078
+ if (description) argv.push('--description', description);
3079
+ return argv;
3080
+ },
3081
+ 'payment get': async (io) => {
3082
+ const b = await pickBusiness(io);
3083
+ if (!b) return null;
3084
+ const p = await pickPayment(io, b);
3085
+ return p ? ['payment', 'get', p] : null;
3086
+ },
3087
+ 'payment qr': async (io) => {
3088
+ const b = await pickBusiness(io);
3089
+ if (!b) return null;
3090
+ const p = await pickPayment(io, b);
3091
+ return p ? ['payment', 'qr', p] : null;
3092
+ },
3093
+ 'payment list': async (io) => {
3094
+ const b = await pickBusiness(io);
3095
+ return b ? ['payment', 'list', '--business-id', b] : null;
3096
+ },
3097
+ };
3098
+
3099
+ // Fallback menu (used only if @inquirer/prompts can't be loaded): a numbered
3100
+ // list, then a prompt for the subcommand + args.
3101
+ async function numberedMenu() {
3102
+ console.log('');
3103
+ console.log(` ${colors.bright}${colors.cyan}coinpay${colors.reset} · interactive menu (v${VERSION})`);
3104
+ for (;;) {
3105
+ console.log('');
3106
+ MENU_COMMANDS.forEach(([name, desc], i) => {
3107
+ const num = colors.yellow + String(i + 1).padStart(2) + colors.reset;
3108
+ console.log(` ${num} ${colors.bright}${name.padEnd(11)}${colors.reset}${desc}`);
3109
+ });
3110
+ console.log('');
3111
+ const answer = (await askLine(' pick a number, type a command (with args), or q to quit: ')).trim();
3112
+ if (!answer || answer === 'q' || answer === 'quit' || answer === 'exit') break;
3113
+
3114
+ let argv;
3115
+ if (/^\d+$/.test(answer)) {
3116
+ const idx = parseInt(answer, 10) - 1;
3117
+ if (idx < 0 || idx >= MENU_COMMANDS.length) { print.error(' invalid choice'); continue; }
3118
+ const name = MENU_COMMANDS[idx][0];
3119
+ const hint = (SUBCOMMANDS[name] || []).map((s) => s[0]).join(', ');
3120
+ const rest = (await askLine(
3121
+ ` coinpay ${colors.bright}${name}${colors.reset} ▸ subcommand + args${hint ? ` (${hint})` : ''} — Enter to list: `
3122
+ )).trim();
3123
+ argv = [name, ...(rest ? rest.split(/\s+/) : [])];
3124
+ } else {
3125
+ argv = answer.split(/\s+/);
3126
+ }
3127
+ console.log('');
3128
+ await runChild(argv);
3129
+ }
3130
+ }
3131
+
3132
+ // Arrow-key drill-down menu (parity with the ugig CLI): pick a command, pick a
3133
+ // subcommand, fill in args, run — looping until you quit. Falls back to the
3134
+ // numbered menu if @inquirer/prompts isn't installed.
3135
+ async function runInteractiveMenu() {
3136
+ let select, input;
3137
+ try { ({ select, input } = await import('@inquirer/prompts')); }
3138
+ catch { return numberedMenu(); }
3139
+
3140
+ const BACK = '\x00back';
3141
+ const TYPE = '\x00type';
3142
+ const splitArgs = (s) => (s ? s.trim().split(/\s+/).filter(Boolean) : []);
3143
+
3144
+ // Who's signed in (refreshed after login/logout). Shown in the menu header.
3145
+ let me = await fetchMe();
3146
+
3147
+ for (;;) {
3148
+ let cmd;
3149
+ try {
3150
+ cmd = await select({
3151
+ message: `coinpay — ${me ? `signed in as ${me.email}` : 'not signed in (pick login)'} — pick a command:`,
3152
+ choices: MENU_COMMANDS.map(([name, desc]) => ({ name: `${name.padEnd(11)} ${desc}`, value: name })),
3153
+ pageSize: 20,
3154
+ });
3155
+ } catch { break; } // Ctrl-C / ESC quits
3156
+
3157
+ const subs = SUBCOMMANDS[cmd] || [];
3158
+ let argv;
3159
+ if (subs.length) {
3160
+ let sub;
3161
+ try {
3162
+ sub = await select({
3163
+ message: `coinpay ${cmd} — pick a subcommand:`,
3164
+ choices: [
3165
+ ...subs.map(([n, d]) => ({ name: `${n.padEnd(14)} ${d || ''}`.trimEnd(), value: n })),
3166
+ { name: '✎ type subcommand + args…', value: TYPE },
3167
+ { name: '↩ back', value: BACK },
3168
+ ],
3169
+ pageSize: 20,
3170
+ });
3171
+ } catch { continue; }
3172
+ if (sub === BACK) continue;
3173
+ if (sub === TYPE) {
3174
+ let line; try { line = await input({ message: `coinpay ${cmd}` }); } catch { continue; }
3175
+ argv = [cmd, ...splitArgs(line)];
3176
+ } else if (GUIDED[`${cmd} ${sub}`]) {
3177
+ // Guided flow: fetch your data + ask for the inputs, then run.
3178
+ let g; try { g = await GUIDED[`${cmd} ${sub}`]({ select, input }); } catch { continue; }
3179
+ if (!g) continue;
3180
+ argv = g;
3181
+ } else {
3182
+ let extra; try { extra = await input({ message: `args/flags for 'coinpay ${cmd} ${sub}' (optional):` }); } catch { continue; }
3183
+ argv = [cmd, sub, ...splitArgs(extra)];
3184
+ }
3185
+ } else {
3186
+ let line; try { line = await input({ message: `coinpay ${cmd}` }); } catch { continue; }
3187
+ argv = [cmd, ...splitArgs(line)];
3188
+ }
3189
+ console.log('');
3190
+ await runChild(argv);
3191
+ console.log('');
3192
+ // login/logout change who's signed in — refresh the header.
3193
+ if (argv[0] === 'login' || argv[0] === 'logout') me = await fetchMe();
3194
+ }
3195
+ }
3196
+
3197
+ // Headless login: ask the server for a device code, show a URL to approve on any
3198
+ // device, and poll until a merchant session token is minted and stored. No
3199
+ // password on this machine — works over SSH / on a server.
3200
+ async function handleLogin() {
3201
+ const cfg = loadConfig();
3202
+ const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
3203
+
3204
+ let start;
3205
+ try {
3206
+ const res = await fetch(`${base}/cli-auth/start`, {
3207
+ method: 'POST',
3208
+ headers: { 'content-type': 'application/json' },
3209
+ body: JSON.stringify({ client_name: hostname() }),
3210
+ });
3211
+ if (!res.ok) throw new Error(`server returned ${res.status}`);
3212
+ start = await res.json();
3213
+ } catch (err) {
3214
+ print.error(`Couldn't start login: ${err.message}`);
3215
+ process.exit(1);
3216
+ }
3217
+
3218
+ print.info('');
3219
+ console.log(' To log in, open this URL on any device (e.g. your desktop) and approve:');
3220
+ console.log('');
3221
+ console.log(' ' + colors.cyan + start.verification_uri_complete + colors.reset);
3222
+ console.log('');
3223
+ console.log(' …or go to ' + colors.cyan + start.verification_uri + colors.reset +
3224
+ ' and enter code ' + colors.bright + start.user_code + colors.reset);
3225
+ console.log('');
3226
+ print.info('Waiting for approval… (Ctrl-C to cancel)');
3227
+
3228
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
3229
+ const intervalMs = Math.max(1, start.interval || 5) * 1000;
3230
+ while (Date.now() < deadline) {
3231
+ await new Promise((r) => setTimeout(r, intervalMs));
3232
+ let data = {};
3233
+ try {
3234
+ const res = await fetch(`${base}/cli-auth/poll`, {
3235
+ method: 'POST',
3236
+ headers: { 'content-type': 'application/json' },
3237
+ body: JSON.stringify({ device_code: start.device_code }),
3238
+ });
3239
+ if (res.status === 202) continue; // authorization_pending
3240
+ data = await res.json().catch(() => ({}));
3241
+ } catch {
3242
+ continue; // transient network error — keep polling
3243
+ }
3244
+ if (data.status === 'complete' && data.token) {
3245
+ cfg.jwtToken = data.token;
3246
+ saveConfig(cfg);
3247
+ const me = await fetchMe();
3248
+ print.success(me ? `Logged in as ${meLabel(me)}` : 'Logged in! Session saved to ' + CONFIG_FILE);
3249
+ return;
3250
+ }
3251
+ if (data.status === 'denied') { print.error('Request denied.'); process.exit(1); }
3252
+ if (data.status === 'expired') { print.error('Login request expired — run `coinpay login` again.'); process.exit(1); }
3253
+ }
3254
+ print.error('Timed out waiting for approval.');
3255
+ process.exit(1);
3256
+ }
3257
+
3258
+ function handleLogout() {
3259
+ const cfg = loadConfig();
3260
+ delete cfg.jwtToken;
3261
+ saveConfig(cfg);
3262
+ print.success('Logged out — session removed from ' + CONFIG_FILE);
3263
+ }
3264
+
3265
+ // Fetch the signed-in merchant (via the stored session token), or null if not
3266
+ // logged in / the token is stale. Used to greet after login, for `whoami`, and
3267
+ // to show who you are in the interactive menu.
3268
+ async function fetchMe() {
3269
+ const cfg = loadConfig();
3270
+ if (!cfg.jwtToken) return null;
3271
+ try {
3272
+ const client = new CoinPayClient({ apiKey: cfg.jwtToken, baseUrl: getBaseUrl() });
3273
+ const { getMe } = await import('../src/auth.js');
3274
+ const result = await getMe(client);
3275
+ return result?.merchant || result || null;
3276
+ } catch {
3277
+ return null;
3278
+ }
3279
+ }
3280
+
3281
+ function meLabel(me) {
3282
+ if (!me) return null;
3283
+ return me.name ? `${me.name} <${me.email}>` : me.email;
3284
+ }
3285
+
3286
+ async function handleWhoami(flags) {
3287
+ const me = await fetchMe();
3288
+ if (!me) {
3289
+ print.error('Not signed in. Run: coinpay login');
3290
+ process.exitCode = 1;
3291
+ return;
3292
+ }
3293
+ print.success(`Signed in as ${meLabel(me)}`);
3294
+ if (me.id) print.info(`Merchant ID: ${me.id}`);
3295
+ if (flags?.json) print.json(me);
3296
+ }
3297
+
2562
3298
  async function main() {
2563
3299
  const { command, subcommand, args, flags } = parseArgs(process.argv.slice(2));
2564
3300
 
@@ -2567,7 +3303,18 @@ async function main() {
2567
3303
  return;
2568
3304
  }
2569
3305
 
2570
- if (flags.help || flags.h || !command) {
3306
+ if (flags.help || flags.h) {
3307
+ showHelp();
3308
+ return;
3309
+ }
3310
+
3311
+ if (!command) {
3312
+ // No command in an interactive terminal → browse-and-run menu instead of a
3313
+ // bare help dump. Piped/non-TTY invocations keep printing help.
3314
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3315
+ await runInteractiveMenu();
3316
+ return;
3317
+ }
2571
3318
  showHelp();
2572
3319
  return;
2573
3320
  }
@@ -2715,6 +3462,18 @@ async function handleLightning(subcommand, args, flags) {
2715
3462
  }
2716
3463
 
2717
3464
  switch (command) {
3465
+ case 'login':
3466
+ await handleLogin();
3467
+ break;
3468
+
3469
+ case 'logout':
3470
+ handleLogout();
3471
+ break;
3472
+
3473
+ case 'whoami':
3474
+ await handleWhoami(flags);
3475
+ break;
3476
+
2718
3477
  case 'config':
2719
3478
  await handleConfig(subcommand, args);
2720
3479
  break;
@@ -2722,6 +3481,14 @@ async function handleLightning(subcommand, args, flags) {
2722
3481
  case 'payment':
2723
3482
  await handlePayment(subcommand, args, flags);
2724
3483
  break;
3484
+
3485
+ case 'invoice':
3486
+ await handleInvoice(subcommand, args, flags);
3487
+ break;
3488
+
3489
+ case 'tokens':
3490
+ await handleTokens(subcommand, args, flags);
3491
+ break;
2725
3492
 
2726
3493
  case 'business':
2727
3494
  await handleBusiness(subcommand, args, flags);
@@ -2775,7 +3542,18 @@ async function handleLightning(subcommand, args, flags) {
2775
3542
  case 'oauth':
2776
3543
  await handleOAuth(subcommand, args, flags);
2777
3544
  break;
2778
-
3545
+
3546
+ case 'update':
3547
+ case 'upgrade':
3548
+ case 'self-update':
3549
+ handleSelfManage('update');
3550
+ break;
3551
+
3552
+ case 'remove':
3553
+ case 'uninstall':
3554
+ handleSelfManage('remove');
3555
+ break;
3556
+
2779
3557
  default:
2780
3558
  print.error(`Unknown command: ${command}`);
2781
3559
  showHelp();
@@ -2896,6 +3674,108 @@ async function handleOAuth(subcommand, args, flags) {
2896
3674
  }
2897
3675
  }
2898
3676
 
3677
+ /**
3678
+ * Open the platform's browser at a URL.
3679
+ *
3680
+ * Failure is not fatal: the URL is printed either way, so a headless box or an
3681
+ * unusual desktop just means the user opens it themselves.
3682
+ */
3683
+ async function openInBrowser(url) {
3684
+ const { spawn } = await import('node:child_process');
3685
+ const command =
3686
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
3687
+ try {
3688
+ spawn(command, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' })
3689
+ .on('error', () => {})
3690
+ .unref();
3691
+ return true;
3692
+ } catch {
3693
+ return false;
3694
+ }
3695
+ }
3696
+
3697
+ /**
3698
+ * `coinpay x402 pay <url>` — fetch a paid resource, approving in the browser.
3699
+ *
3700
+ * The terminal cannot sign: browser wallets expose themselves to pages, not to
3701
+ * processes. So this serves a one-shot approval page on loopback, opens it,
3702
+ * and waits for the signed `X-PAYMENT` header to come back before retrying the
3703
+ * original request.
3704
+ */
3705
+ async function handleX402Pay(args, flags) {
3706
+ const target = args[0] || flags.url;
3707
+ if (!target) {
3708
+ print.error('Usage: coinpay x402 pay <url>');
3709
+ process.exit(1);
3710
+ }
3711
+
3712
+ const { startPaymentServer } = await import('../src/x402-pay-server.js');
3713
+
3714
+ print.info(`Fetching ${target}`);
3715
+ const first = await fetch(target);
3716
+
3717
+ if (first.status !== 402) {
3718
+ // Nothing to pay for. Still the useful answer, so print it rather than
3719
+ // treating "free" as an error.
3720
+ print.success(`No payment required (HTTP ${first.status})`);
3721
+ const body = await first.text();
3722
+ if (flags.output) {
3723
+ await (await import('node:fs/promises')).writeFile(flags.output, body);
3724
+ print.info(`Wrote ${flags.output}`);
3725
+ } else {
3726
+ console.log(body);
3727
+ }
3728
+ return;
3729
+ }
3730
+
3731
+ let paymentRequired;
3732
+ try {
3733
+ paymentRequired = await first.clone().json();
3734
+ } catch {
3735
+ print.error('Resource returned 402 but its body was not valid x402 JSON');
3736
+ process.exit(1);
3737
+ }
3738
+
3739
+ const server = await startPaymentServer({
3740
+ paymentRequired,
3741
+ resourceUrl: target,
3742
+ port: flags.port ? Number(flags.port) : 0,
3743
+ });
3744
+
3745
+ try {
3746
+ if (flags['no-open']) {
3747
+ print.info(`Open this to approve:\n ${server.url}`);
3748
+ } else {
3749
+ await openInBrowser(server.url);
3750
+ print.info(`Approve in your browser:\n ${server.url}`);
3751
+ }
3752
+ print.info('Waiting for approval…');
3753
+
3754
+ const header = await server.waitForPayment();
3755
+ print.success('Payment approved');
3756
+
3757
+ const paid = await fetch(target, { headers: { 'X-PAYMENT': header } });
3758
+ if (paid.status === 402) {
3759
+ // Never sign again automatically — a second 402 means the payment was
3760
+ // refused rather than missing, and retrying spends the user's money on a
3761
+ // resource that just said no.
3762
+ print.error('Payment was rejected by the resource. Not retrying.');
3763
+ process.exit(1);
3764
+ }
3765
+
3766
+ const body = await paid.text();
3767
+ if (flags.output) {
3768
+ await (await import('node:fs/promises')).writeFile(flags.output, body);
3769
+ print.success(`HTTP ${paid.status} — wrote ${flags.output}`);
3770
+ } else {
3771
+ print.success(`HTTP ${paid.status}`);
3772
+ console.log(body);
3773
+ }
3774
+ } finally {
3775
+ await server.close();
3776
+ }
3777
+ }
3778
+
2899
3779
  /**
2900
3780
  * Handle x402 commands
2901
3781
  */
@@ -2911,16 +3791,23 @@ Usage: coinpay x402 <command> [options]
2911
3791
  Commands:
2912
3792
  status Check x402 facilitator status
2913
3793
  test Test x402 flow against an endpoint
3794
+ pay <url> Fetch a paid URL, approving payment in your browser
2914
3795
 
2915
3796
  Options:
2916
3797
  --url <url> Endpoint URL for testing
2917
3798
  --network <net> Network: base, ethereum, polygon (default: base)
2918
3799
  --amount <amt> Amount in USDC smallest unit (default: 1000000 = 1 USDC)
3800
+ --output <file> (pay) Write the paid response body to a file
3801
+ --port <n> (pay) Port for the local approval page (default: random)
3802
+ --no-open (pay) Print the URL instead of opening a browser
2919
3803
  `);
2920
3804
  return;
2921
3805
  }
2922
3806
 
2923
3807
  switch (subcommand) {
3808
+ case 'pay':
3809
+ return handleX402Pay(args, flags);
3810
+
2924
3811
  case 'status': {
2925
3812
  const apiKey = flags['api-key'] || config.apiKey;
2926
3813
  const baseUrl = flags['base-url'] || config.baseUrl || 'https://coinpayportal.com';