@profullstack/coinpay 0.6.10 → 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
@@ -244,6 +325,12 @@ ${colors.cyan}Commands:${colors.reset}
244
325
  events <id> Get escrow audit log
245
326
  auth <id> Authenticate with escrow token
246
327
 
328
+ ${colors.bright}oauth${colors.reset}
329
+ list List OAuth clients
330
+ create Create a new OAuth client
331
+ get <id> Get OAuth client details
332
+ delete <id> Delete an OAuth client
333
+
247
334
  ${colors.bright}reputation${colors.reset}
248
335
  submit Submit a task receipt
249
336
  query <agent-did> Query agent reputation
@@ -253,6 +340,7 @@ ${colors.cyan}Commands:${colors.reset}
253
340
  badge [did] Get embeddable reputation badge URL
254
341
  verify <id> Verify a credential
255
342
  revocations List revoked credentials
343
+ did setup Use existing DID or create one automatically
256
344
  did claim Claim (auto-generate) a new DID
257
345
  did me Show your current DID
258
346
  issuer register Register platform issuer (--name, --domain)
@@ -264,6 +352,12 @@ ${colors.cyan}Commands:${colors.reset}
264
352
  logs <business-id> Get webhook logs
265
353
  test <business-id> Send test webhook
266
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
+
267
361
  ${colors.cyan}Wallet Options:${colors.reset}
268
362
  --words <12|24> Number of mnemonic words (default: 12)
269
363
  --chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
@@ -320,6 +414,12 @@ ${colors.cyan}Examples:${colors.reset}
320
414
  coinpay swap quote --from BTC --to ETH --amount 0.1
321
415
  coinpay swap create --from BTC --to ETH --amount 0.1 --settle 0x...
322
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
+
323
423
  ${colors.cyan}Environment Variables:${colors.reset}
324
424
  COINPAY_API_KEY API key (overrides config)
325
425
  COINPAY_BASE_URL Custom API URL
@@ -382,6 +482,51 @@ async function promptYesNo(question, defaultYes = true) {
382
482
  return answer.toLowerCase().startsWith('y');
383
483
  }
384
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
+
385
530
  /**
386
531
  * Check if gpg is available
387
532
  */
@@ -409,24 +554,22 @@ async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
409
554
  createdAt: new Date().toISOString(),
410
555
  });
411
556
 
412
- const tmpFile = join(tmpdir(), `coinpay-wallet-${Date.now()}.json`);
413
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
414
-
415
- try {
416
- writeFileSync(tmpFile, content, { mode: 0o600 });
417
- writeFileSync(passFile, password, { mode: 0o600 });
418
-
419
- execSync(
420
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --symmetric --cipher-algo AES256 --output "${walletFile}" "${tmpFile}"`,
421
- { stdio: ['pipe', 'pipe', 'pipe'] }
422
- );
423
-
424
- return true;
425
- } finally {
426
- // Secure cleanup
427
- try { writeFileSync(tmpFile, Buffer.alloc(content.length, 0)); unlinkSync(tmpFile); } catch {}
428
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
429
- }
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;
430
573
  }
431
574
 
432
575
  /**
@@ -441,21 +584,18 @@ async function loadEncryptedWallet(password, walletFile) {
441
584
  throw new Error(`Wallet file not found: ${walletFile}`);
442
585
  }
443
586
 
444
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
445
-
446
- try {
447
- writeFileSync(passFile, password, { mode: 0o600 });
448
-
449
- const result = execSync(
450
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --decrypt "${walletFile}"`,
451
- { stdio: ['pipe', 'pipe', 'pipe'] }
452
- );
453
-
454
- const data = JSON.parse(result.toString());
455
- return data;
456
- } finally {
457
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
458
- }
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());
459
599
  }
460
600
 
461
601
  /**
@@ -631,6 +771,200 @@ async function handlePayment(subcommand, args, flags) {
631
771
  }
632
772
  }
633
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
+
634
968
  /**
635
969
  * Business commands
636
970
  */
@@ -1830,17 +2164,18 @@ async function handleAuth(subcommand, args, flags) {
1830
2164
  const client = new CoinPayClient({ apiKey: jwtToken, baseUrl: getBaseUrl() });
1831
2165
  const { getMe } = await import('../src/auth.js');
1832
2166
  const result = await getMe(client);
1833
-
2167
+ const me = result?.merchant || result;
2168
+
1834
2169
  print.success('Current merchant:');
1835
- print.info(`ID: ${result.id}`);
1836
- print.info(`Email: ${result.email}`);
1837
- if (result.name) {
1838
- 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}`);
1839
2174
  }
1840
- print.info(`Created: ${result.created_at}`);
1841
-
2175
+ if (me.created_at) print.info(`Created: ${me.created_at}`);
2176
+
1842
2177
  if (flags.json) {
1843
- print.json(result);
2178
+ print.json(me);
1844
2179
  }
1845
2180
  } catch (error) {
1846
2181
  if (error.message.includes('401') || error.message.includes('403')) {
@@ -2007,15 +2342,28 @@ async function handleReputation(subcommand, args, flags) {
2007
2342
  const client = createClient();
2008
2343
 
2009
2344
  if (didSubcommand === 'claim') {
2010
- const { claimDid } = await import('../src/reputation.js');
2011
- 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);
2012
2359
 
2013
2360
  if (result.did) {
2014
- print.success(`DID claimed: ${result.did}`);
2361
+ print.success(`${existing?.did ? 'DID ready' : 'DID claimed'}: ${result.did}`);
2015
2362
  console.log(` Public Key: ${result.public_key}`);
2016
2363
  console.log(` Verified: ${result.verified}`);
2017
2364
  } else {
2018
2365
  print.error(result.error || 'Failed to claim DID');
2366
+ process.exitCode = 1;
2019
2367
  }
2020
2368
 
2021
2369
  if (flags.json) print.json(result);
@@ -2055,6 +2403,31 @@ async function handleReputation(subcommand, args, flags) {
2055
2403
  print.info('No DID found. Run: coinpay reputation did claim');
2056
2404
  }
2057
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
+
2058
2431
  if (flags.json) print.json(result);
2059
2432
  } else {
2060
2433
  // Default: show current DID
@@ -2285,9 +2658,9 @@ async function handlePayout(subcommand, args, flags) {
2285
2658
 
2286
2659
  switch (subcommand) {
2287
2660
  case 'create': {
2288
- const amount = parseInt(flags.amount);
2289
- if (!amount || amount <= 0) {
2290
- 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)');
2291
2664
  print.info('Example: coinpay payout create --amount 5000 --description "Weekly payout"');
2292
2665
  process.exit(1);
2293
2666
  }
@@ -2429,7 +2802,8 @@ async function handleCard(subcommand, args, flags) {
2429
2802
  case 'onboard': {
2430
2803
  if (!merchantId) {
2431
2804
  print.error('Merchant ID required');
2432
- 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.');
2433
2807
  return;
2434
2808
  }
2435
2809
 
@@ -2553,6 +2927,374 @@ async function handleCard(subcommand, args, flags) {
2553
2927
  /**
2554
2928
  * Main entry point
2555
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
+
2556
3298
  async function main() {
2557
3299
  const { command, subcommand, args, flags } = parseArgs(process.argv.slice(2));
2558
3300
 
@@ -2561,13 +3303,177 @@ async function main() {
2561
3303
  return;
2562
3304
  }
2563
3305
 
2564
- 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
+ }
2565
3318
  showHelp();
2566
3319
  return;
2567
3320
  }
2568
3321
 
2569
3322
  try {
3323
+
3324
+ async function handleLightning(subcommand, args, flags) {
3325
+ const client = createClient();
3326
+
3327
+ switch (subcommand) {
3328
+ case 'enable': {
3329
+ const walletId = flags['wallet-id'] || flags['wallet'];
3330
+ if (!walletId) {
3331
+ console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3332
+ process.exit(1);
3333
+ }
3334
+ const mnemonic = await getDecryptedMnemonic(flags);
3335
+ const result = await client.lightning.enableWallet({
3336
+ wallet_id: walletId,
3337
+ mnemonic,
3338
+ business_id: flags['business-id'],
3339
+ });
3340
+ console.log(colors.green + '⚡ Lightning wallet enabled!' + colors.reset);
3341
+ console.log(JSON.stringify(result, null, 2));
3342
+ break;
3343
+ }
3344
+
3345
+ case 'address': {
3346
+ const walletId = flags['wallet-id'] || flags['wallet'];
3347
+ const username = flags['username'] || args[0];
3348
+ if (!walletId) {
3349
+ console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3350
+ process.exit(1);
3351
+ }
3352
+ if (!username) {
3353
+ // GET current address
3354
+ const result = await client.lightning.getAddress(walletId);
3355
+ if (result.lightning_address) {
3356
+ console.log(colors.green + '⚡ ' + result.lightning_address + colors.reset);
3357
+ } else {
3358
+ console.log('No Lightning Address registered. Use: coinpay ln address --username <name>');
3359
+ }
3360
+ } else {
3361
+ // POST register address
3362
+ const result = await client.lightning.registerAddress({ wallet_id: walletId, username });
3363
+ console.log(colors.green + '⚡ Registered: ' + result.lightning_address + colors.reset);
3364
+ }
3365
+ break;
3366
+ }
3367
+
3368
+ case 'address-check': {
3369
+ const username = flags['username'] || args[0];
3370
+ if (!username) {
3371
+ console.error(colors.red + 'Error: --username required' + colors.reset);
3372
+ process.exit(1);
3373
+ }
3374
+ const result = await client.lightning.checkAddressAvailable(username);
3375
+ console.log(result.available
3376
+ ? colors.green + '✓ ' + username + ' is available' + colors.reset
3377
+ : colors.red + '✗ ' + username + ' is taken' + colors.reset);
3378
+ break;
3379
+ }
3380
+
3381
+ case 'invoice': {
3382
+ const walletId = flags['wallet-id'] || flags['wallet'];
3383
+ const amount = parseInt(flags['amount'] || args[0]);
3384
+ if (!walletId || !amount) {
3385
+ console.error(colors.red + 'Error: --wallet-id and --amount required' + colors.reset);
3386
+ process.exit(1);
3387
+ }
3388
+ const result = await client.lightning.createInvoice({
3389
+ wallet_id: walletId,
3390
+ amount_sats: amount,
3391
+ description: flags['description'] || flags['desc'] || '',
3392
+ });
3393
+ console.log(colors.green + '⚡ Invoice created' + colors.reset);
3394
+ console.log(JSON.stringify(result, null, 2));
3395
+ break;
3396
+ }
3397
+
3398
+ case 'send': {
3399
+ const walletId = flags['wallet-id'] || flags['wallet'];
3400
+ const destination = flags['to'] || args[0];
3401
+ const amount = flags['amount'] ? parseInt(flags['amount']) : undefined;
3402
+ if (!walletId || !destination) {
3403
+ console.error(colors.red + 'Error: --wallet-id and --to required' + colors.reset);
3404
+ process.exit(1);
3405
+ }
3406
+ const result = await client.lightning.sendPayment({
3407
+ wallet_id: walletId,
3408
+ destination,
3409
+ amount_sats: amount,
3410
+ });
3411
+ console.log(colors.green + '⚡ Payment sent!' + colors.reset);
3412
+ console.log(JSON.stringify(result, null, 2));
3413
+ break;
3414
+ }
3415
+
3416
+ case 'payments': {
3417
+ const walletId = flags['wallet-id'] || flags['wallet'];
3418
+ if (!walletId) {
3419
+ console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3420
+ process.exit(1);
3421
+ }
3422
+ const result = await client.lightning.listPayments({
3423
+ wallet_id: walletId,
3424
+ direction: flags['direction'],
3425
+ limit: flags['limit'] ? parseInt(flags['limit']) : undefined,
3426
+ });
3427
+ const payments = result.data?.payments || result.payments || [];
3428
+ if (payments.length === 0) {
3429
+ console.log('No Lightning payments found.');
3430
+ } else {
3431
+ for (const p of payments) {
3432
+ const dir = p.direction === 'outgoing' ? colors.red + '↑ SENT' : colors.green + '↓ RECV';
3433
+ const sats = Math.floor((p.amount_msat || 0) / 1000);
3434
+ console.log(`${dir}${colors.reset} ${sats} sats ${p.status} ${p.created_at}`);
3435
+ }
3436
+ }
3437
+ break;
3438
+ }
3439
+
3440
+ case 'balance': {
3441
+ const walletId = flags['wallet-id'] || flags['wallet'];
3442
+ if (!walletId) {
3443
+ console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3444
+ process.exit(1);
3445
+ }
3446
+ // Use the web-wallet balance endpoint filtered by LN
3447
+ const result = await client.request(`/web-wallet/balance?wallet_id=${walletId}`);
3448
+ const balances = result.data?.balances || result.balances || [];
3449
+ const ln = balances.find(b => b.chain === 'LN' || b.currency === 'LN');
3450
+ if (ln) {
3451
+ console.log(colors.green + '⚡ ' + ln.balance + ' BTC (' + Math.floor(parseFloat(ln.balance) * 100_000_000) + ' sats)' + colors.reset);
3452
+ } else {
3453
+ console.log('No Lightning balance. Enable Lightning first: coinpay ln enable');
3454
+ }
3455
+ break;
3456
+ }
3457
+
3458
+ default:
3459
+ console.log(`Unknown lightning command: ${subcommand}`);
3460
+ showHelp();
3461
+ }
3462
+ }
3463
+
2570
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
+
2571
3477
  case 'config':
2572
3478
  await handleConfig(subcommand, args);
2573
3479
  break;
@@ -2575,6 +3481,14 @@ async function main() {
2575
3481
  case 'payment':
2576
3482
  await handlePayment(subcommand, args, flags);
2577
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;
2578
3492
 
2579
3493
  case 'business':
2580
3494
  await handleBusiness(subcommand, args, flags);
@@ -2596,6 +3510,11 @@ async function main() {
2596
3510
  await handleEscrow(subcommand, args, flags);
2597
3511
  break;
2598
3512
 
3513
+ case 'ln':
3514
+ case 'lightning':
3515
+ await handleLightning(subcommand, args, flags);
3516
+ break;
3517
+
2599
3518
  case 'webhook':
2600
3519
  await handleWebhook(subcommand, args, flags);
2601
3520
  break;
@@ -2619,7 +3538,22 @@ async function main() {
2619
3538
  case 'x402':
2620
3539
  await handleX402(subcommand, args, flags);
2621
3540
  break;
2622
-
3541
+
3542
+ case 'oauth':
3543
+ await handleOAuth(subcommand, args, flags);
3544
+ break;
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
+
2623
3557
  default:
2624
3558
  print.error(`Unknown command: ${command}`);
2625
3559
  showHelp();
@@ -2634,6 +3568,214 @@ async function main() {
2634
3568
  }
2635
3569
  }
2636
3570
 
3571
+ /**
3572
+ * OAuth commands
3573
+ */
3574
+ async function handleOAuth(subcommand, args, flags) {
3575
+ const client = createClient();
3576
+
3577
+ switch (subcommand) {
3578
+ case 'list': {
3579
+ const result = await client.listOAuthClients();
3580
+ const clients = result.clients || [];
3581
+
3582
+ if (clients.length === 0) {
3583
+ print.info('No OAuth clients found.');
3584
+ } else {
3585
+ console.log(`\n${colors.bright}OAuth Clients${colors.reset} (${clients.length})\n`);
3586
+ for (const c of clients) {
3587
+ console.log(` ${colors.bright}${c.name}${colors.reset}`);
3588
+ console.log(` Client ID: ${c.client_id}`);
3589
+ if (c.description) console.log(` Description: ${c.description}`);
3590
+ if (c.redirect_uris) console.log(` Redirect URIs: ${c.redirect_uris.join(', ')}`);
3591
+ if (c.scopes) console.log(` Scopes: ${Array.isArray(c.scopes) ? c.scopes.join(', ') : c.scopes}`);
3592
+ console.log();
3593
+ }
3594
+ }
3595
+
3596
+ if (flags.json) print.json(result);
3597
+ break;
3598
+ }
3599
+
3600
+ case 'create': {
3601
+ const name = flags.name;
3602
+ const redirectUri = flags['redirect-uri'];
3603
+ const scope = flags.scope;
3604
+ const description = flags.description;
3605
+
3606
+ if (!name || !redirectUri) {
3607
+ print.error('Required: --name, --redirect-uri');
3608
+ print.info('Example: coinpay oauth create --name "My App" --redirect-uri https://myapp.com/callback --scope openid,profile');
3609
+ return;
3610
+ }
3611
+
3612
+ const redirectUris = redirectUri.split(',').map(u => u.trim());
3613
+ const scopes = scope ? scope.split(',').map(s => s.trim()) : undefined;
3614
+
3615
+ const result = await client.createOAuthClient({
3616
+ name,
3617
+ redirectUris,
3618
+ scopes,
3619
+ description,
3620
+ });
3621
+
3622
+ print.success('OAuth client created');
3623
+ if (result.client_id) {
3624
+ console.log(`\n ${colors.bright}Client ID:${colors.reset} ${result.client_id}`);
3625
+ }
3626
+ if (result.client_secret) {
3627
+ console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${result.client_secret}${colors.reset}`);
3628
+ console.log();
3629
+ print.warn('Save the client secret — it is only shown once!');
3630
+ }
3631
+
3632
+ if (flags.json) print.json(result);
3633
+ break;
3634
+ }
3635
+
3636
+ case 'get': {
3637
+ const id = args[0];
3638
+ if (!id) {
3639
+ print.error('OAuth client ID required');
3640
+ print.info('Usage: coinpay oauth get <client-id>');
3641
+ return;
3642
+ }
3643
+
3644
+ const result = await client.getOAuthClient(id);
3645
+
3646
+ print.success(`OAuth Client: ${result.name || id}`);
3647
+ if (result.client_id) print.info(` Client ID: ${result.client_id}`);
3648
+ if (result.description) print.info(` Description: ${result.description}`);
3649
+ if (result.redirect_uris) print.info(` Redirect URIs: ${result.redirect_uris.join(', ')}`);
3650
+ if (result.scopes) print.info(` Scopes: ${Array.isArray(result.scopes) ? result.scopes.join(', ') : result.scopes}`);
3651
+ if (result.created_at) print.info(` Created: ${result.created_at}`);
3652
+
3653
+ if (flags.json) print.json(result);
3654
+ break;
3655
+ }
3656
+
3657
+ case 'delete': {
3658
+ const id = args[0];
3659
+ if (!id) {
3660
+ print.error('OAuth client ID required');
3661
+ print.info('Usage: coinpay oauth delete <client-id>');
3662
+ return;
3663
+ }
3664
+
3665
+ await client.deleteOAuthClient(id);
3666
+ print.success(`OAuth client ${id} deleted`);
3667
+ break;
3668
+ }
3669
+
3670
+ default:
3671
+ print.error(`Unknown oauth command: ${subcommand}`);
3672
+ print.info('Available: list, create, get, delete');
3673
+ process.exit(1);
3674
+ }
3675
+ }
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
+
2637
3779
  /**
2638
3780
  * Handle x402 commands
2639
3781
  */
@@ -2649,16 +3791,23 @@ Usage: coinpay x402 <command> [options]
2649
3791
  Commands:
2650
3792
  status Check x402 facilitator status
2651
3793
  test Test x402 flow against an endpoint
3794
+ pay <url> Fetch a paid URL, approving payment in your browser
2652
3795
 
2653
3796
  Options:
2654
3797
  --url <url> Endpoint URL for testing
2655
3798
  --network <net> Network: base, ethereum, polygon (default: base)
2656
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
2657
3803
  `);
2658
3804
  return;
2659
3805
  }
2660
3806
 
2661
3807
  switch (subcommand) {
3808
+ case 'pay':
3809
+ return handleX402Pay(args, flags);
3810
+
2662
3811
  case 'status': {
2663
3812
  const apiKey = flags['api-key'] || config.apiKey;
2664
3813
  const baseUrl = flags['base-url'] || config.baseUrl || 'https://coinpayportal.com';