@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/CHANGELOG.md +7 -0
- package/README.md +64 -0
- package/bin/coinpay.js +1208 -59
- package/package.json +20 -3
- package/src/auth.js +8 -1
- package/src/client.d.ts +52 -0
- package/src/client.js +138 -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/lightning.js +101 -39
- 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
|
|
@@ -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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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: ${
|
|
1836
|
-
print.info(`Email: ${
|
|
1837
|
-
if (
|
|
1838
|
-
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}`);
|
|
1839
2174
|
}
|
|
1840
|
-
print.info(`Created: ${
|
|
1841
|
-
|
|
2175
|
+
if (me.created_at) print.info(`Created: ${me.created_at}`);
|
|
2176
|
+
|
|
1842
2177
|
if (flags.json) {
|
|
1843
|
-
print.json(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
2289
|
-
if (
|
|
2290
|
-
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)');
|
|
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
|
|
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';
|