@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/README.md +64 -0
- package/bin/coinpay.js +946 -59
- package/package.json +20 -3
- package/src/auth.js +2 -1
- package/src/client.d.ts +52 -0
- package/src/client.js +53 -7
- package/src/index.d.ts +34 -1
- package/src/index.js +32 -0
- package/src/invoices.d.ts +77 -0
- package/src/invoices.js +237 -0
- package/src/tokens.d.ts +14 -0
- package/src/tokens.js +34 -0
- package/src/wallet.d.ts +11 -0
- package/src/wallet.js +229 -1
- package/src/webhooks.js +37 -3
- package/src/x402-browser.js +318 -0
- package/src/x402-pay-server.js +272 -0
- package/src/x402-v2.js +416 -0
- package/src/x402.js +164 -19
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
|
|
27
|
-
const
|
|
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(/^~/,
|
|
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(/^~/,
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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: ${
|
|
1842
|
-
print.info(`Email: ${
|
|
1843
|
-
if (
|
|
1844
|
-
print.info(`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: ${
|
|
1847
|
-
|
|
2175
|
+
if (me.created_at) print.info(`Created: ${me.created_at}`);
|
|
2176
|
+
|
|
1848
2177
|
if (flags.json) {
|
|
1849
|
-
print.json(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
2295
|
-
if (
|
|
2296
|
-
print.error('--amount
|
|
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
|
|
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';
|