mppx 0.4.1 → 0.4.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +260 -0
  2. package/dist/bin.js +2 -2
  3. package/dist/bin.js.map +1 -1
  4. package/dist/cli/account.d.ts +53 -0
  5. package/dist/cli/account.d.ts.map +1 -0
  6. package/dist/cli/account.js +156 -0
  7. package/dist/cli/account.js.map +1 -0
  8. package/dist/{cli.d.ts → cli/cli.d.ts} +4 -3
  9. package/dist/cli/cli.d.ts.map +1 -0
  10. package/dist/cli/cli.js +852 -0
  11. package/dist/cli/cli.js.map +1 -0
  12. package/dist/cli/config.d.ts +39 -0
  13. package/dist/cli/config.d.ts.map +1 -0
  14. package/dist/cli/config.js +30 -0
  15. package/dist/cli/config.js.map +1 -0
  16. package/dist/cli/internal.d.ts +16 -0
  17. package/dist/cli/internal.d.ts.map +1 -0
  18. package/dist/cli/internal.js +58 -0
  19. package/dist/cli/internal.js.map +1 -0
  20. package/dist/cli/plugins/index.d.ts +4 -0
  21. package/dist/cli/plugins/index.d.ts.map +1 -0
  22. package/dist/cli/plugins/index.js +4 -0
  23. package/dist/cli/plugins/index.js.map +1 -0
  24. package/dist/cli/plugins/plugin.d.ts +68 -0
  25. package/dist/cli/plugins/plugin.d.ts.map +1 -0
  26. package/dist/cli/plugins/plugin.js +4 -0
  27. package/dist/cli/plugins/plugin.js.map +1 -0
  28. package/dist/cli/plugins/stripe.d.ts +2 -0
  29. package/dist/cli/plugins/stripe.d.ts.map +1 -0
  30. package/dist/cli/plugins/stripe.js +118 -0
  31. package/dist/cli/plugins/stripe.js.map +1 -0
  32. package/dist/cli/plugins/tempo.d.ts +11 -0
  33. package/dist/cli/plugins/tempo.d.ts.map +1 -0
  34. package/dist/cli/plugins/tempo.js +706 -0
  35. package/dist/cli/plugins/tempo.js.map +1 -0
  36. package/dist/cli/utils.d.ts +93 -0
  37. package/dist/cli/utils.d.ts.map +1 -0
  38. package/dist/cli/utils.js +274 -0
  39. package/dist/cli/utils.js.map +1 -0
  40. package/dist/tempo/client/Methods.d.ts +1 -1
  41. package/dist/tempo/client/Session.d.ts +2 -2
  42. package/package.json +12 -1
  43. package/src/bin.ts +2 -2
  44. package/src/cli/account.ts +157 -0
  45. package/src/{cli.test.ts → cli/cli.test.ts} +107 -51
  46. package/src/cli/cli.ts +907 -0
  47. package/src/cli/config.test.ts +82 -0
  48. package/src/cli/config.ts +44 -0
  49. package/src/cli/internal.ts +72 -0
  50. package/src/cli/plugins/index.ts +3 -0
  51. package/src/cli/plugins/plugin.ts +73 -0
  52. package/src/cli/plugins/stripe.ts +143 -0
  53. package/src/cli/plugins/tempo.ts +842 -0
  54. package/src/cli/utils.ts +336 -0
  55. package/dist/cli.d.ts.map +0 -1
  56. package/dist/cli.js +0 -1992
  57. package/dist/cli.js.map +0 -1
  58. package/src/cli.ts +0 -2178
@@ -0,0 +1,852 @@
1
+ import * as fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import * as path from 'node:path';
4
+ import { Cli, Errors, z } from 'incur';
5
+ import { Base64 } from 'ox';
6
+ import { createClient, http } from 'viem';
7
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
8
+ import { tempo as tempoMainnet } from 'viem/chains';
9
+ import * as Challenge from '../Challenge.js';
10
+ import * as Mppx from '../client/Mppx.js';
11
+ import { createDefaultStore, createKeychain, resolveAccountName } from './account.js';
12
+ import { loadConfig, resolvePlugin } from './internal.js';
13
+ import { readTempoKeystore, resolveTempoAccount } from './plugins/tempo.js';
14
+ import { chainName, confirm, decodeMemo, fetchBalanceLines, fmtBalance, fmtChallengeValue, fmtRequestValue, isTempoAccount, link, parseMethodOpts, pc, printRequestHeaders, printResponseHeaders, prompt, resolveChain, } from './utils.js';
15
+ const packageJson = createRequire(import.meta.url)('../../package.json');
16
+ const cli = Cli.create('mppx', {
17
+ version: packageJson.version,
18
+ description: 'Make HTTP requests with automatic payment handling',
19
+ usage: [{ suffix: '<url> [options]' }],
20
+ args: z.object({
21
+ url: z.string().describe('URL to make request to'),
22
+ }),
23
+ options: z.object({
24
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
25
+ config: z.string().optional().describe('Path to config file'),
26
+ confirm: z.boolean().optional().default(false).describe('Show confirmation prompts'),
27
+ data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
28
+ fail: z.boolean().optional().describe('Fail silently on HTTP errors (exit 22)'),
29
+ header: z.array(z.string()).optional().describe('Add header (repeatable)'),
30
+ include: z.boolean().optional().describe('Include response headers in output'),
31
+ insecure: z
32
+ .boolean()
33
+ .optional()
34
+ .describe('Skip TLS certificate verification (true for localhost/.local)'),
35
+ jsonBody: z
36
+ .string()
37
+ .optional()
38
+ .describe('Send JSON body (sets Content-Type and Accept, implies POST)'),
39
+ location: z.boolean().optional().describe('Follow redirects'),
40
+ method: z.string().optional().describe('HTTP method'),
41
+ methodOpt: z
42
+ .array(z.string())
43
+ .optional()
44
+ .describe('Method-specific option (key=value, repeatable)'),
45
+ rpcUrl: z
46
+ .string()
47
+ .optional()
48
+ .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
49
+ silent: z.boolean().default(false).describe('Silent mode (suppress progress and info)'),
50
+ userAgent: z
51
+ .string()
52
+ .optional()
53
+ .default(`${packageJson.name}/${packageJson.version}`)
54
+ .describe('Set User-Agent header'),
55
+ verbose: z
56
+ .number()
57
+ .default(0)
58
+ .meta({ count: true })
59
+ .describe('Verbosity (-v details, -vv headers)'),
60
+ }),
61
+ alias: {
62
+ account: 'a',
63
+ config: 'c',
64
+ data: 'd',
65
+ fail: 'f',
66
+ header: 'H',
67
+ include: 'i',
68
+ insecure: 'k',
69
+ jsonBody: 'J',
70
+ location: 'L',
71
+ method: 'X',
72
+ methodOpt: 'M',
73
+ rpcUrl: 'r',
74
+ silent: 's',
75
+ userAgent: 'A',
76
+ verbose: 'v',
77
+ },
78
+ examples: [{ args: { url: 'mpp.dev/api/ping/paid' }, description: 'Make a payment request' }],
79
+ async run(c) {
80
+ const info = c.options.silent
81
+ ? (_msg) => { }
82
+ : (msg) => process.stderr.write(msg);
83
+ const loaded = await loadConfig(c.options.config);
84
+ if (loaded && c.options.verbose >= 1)
85
+ info(`${pc.dim('Using config')} ${pc.blue(path.relative(process.cwd(), loaded.path))}\n`);
86
+ const headers = {
87
+ 'User-Agent': c.options.userAgent,
88
+ };
89
+ if (c.options.header) {
90
+ for (const header of c.options.header) {
91
+ const index = header.indexOf(':');
92
+ if (index === -1) {
93
+ return c.error({
94
+ code: 'INVALID_HEADER',
95
+ message: `Invalid header format: ${header}`,
96
+ exitCode: 2,
97
+ });
98
+ }
99
+ headers[header.slice(0, index).trim()] = header.slice(index + 1).trim();
100
+ }
101
+ }
102
+ const url = (() => {
103
+ const hasProtocol = /^https?:\/\//.test(c.args.url);
104
+ const isLocal = /^(localhost|.*\.localhost|127\.0\.0\.1|\[::1\])(:\d+)?/.test(c.args.url);
105
+ return hasProtocol ? c.args.url : `${isLocal ? 'http' : 'https'}://${c.args.url}`;
106
+ })();
107
+ const { hostname } = new URL(url);
108
+ if (c.options.insecure ||
109
+ hostname === 'localhost' ||
110
+ hostname.endsWith('.localhost') ||
111
+ hostname.endsWith('.local')) {
112
+ process.removeAllListeners('warning');
113
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
114
+ }
115
+ // Node.js doesn't resolve *.localhost subdomains to loopback (unlike
116
+ // browsers per RFC 6761). Rewrite the URL to 127.0.0.1 and set the
117
+ // Host header so reverse proxies can route correctly.
118
+ const isSubLocalhost = hostname.endsWith('.localhost') && hostname !== 'localhost';
119
+ const fetchUrl = isSubLocalhost ? url.replace(hostname, '127.0.0.1') : url;
120
+ if (isSubLocalhost) {
121
+ const { host } = new URL(url);
122
+ headers.Host = host;
123
+ }
124
+ try {
125
+ const init = { redirect: c.options.location ? 'follow' : 'manual' };
126
+ if (c.options.jsonBody) {
127
+ init.body = c.options.jsonBody;
128
+ headers['Content-Type'] ??= 'application/json';
129
+ headers.Accept ??= 'application/json';
130
+ }
131
+ else if (c.options.data) {
132
+ init.body = c.options.data;
133
+ }
134
+ if (c.options.method)
135
+ init.method = c.options.method.toUpperCase();
136
+ else if (init.body)
137
+ init.method = 'POST';
138
+ if (Object.keys(headers).length > 0)
139
+ init.headers = headers;
140
+ const headerOpts = {
141
+ include: c.options.include ?? false,
142
+ verbose: c.options.verbose,
143
+ silent: c.options.silent,
144
+ };
145
+ if (c.options.verbose >= 2)
146
+ printRequestHeaders(url, init, info);
147
+ const challengeResponse = await globalThis.fetch(fetchUrl, init);
148
+ if (challengeResponse.status !== 402) {
149
+ if (c.options.fail && challengeResponse.status >= 400)
150
+ return c.error({
151
+ code: 'HTTP_ERROR',
152
+ message: `HTTP error ${challengeResponse.status}`,
153
+ exitCode: 22,
154
+ });
155
+ printResponseHeaders(challengeResponse, headerOpts);
156
+ console.log((await challengeResponse.text()).replace(/\n+$/, ''));
157
+ return;
158
+ }
159
+ const challenge = Challenge.fromResponse(challengeResponse);
160
+ const { plugin, method: configMethod } = resolvePlugin(challenge, loaded?.config);
161
+ let tokenSymbol = challenge.request.currency ?? '';
162
+ let tokenDecimals = challenge.request.decimals ?? 6;
163
+ let explorerUrl;
164
+ let pluginResult;
165
+ if (plugin) {
166
+ pluginResult = await plugin.setup({
167
+ challenge,
168
+ options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
169
+ methodOpts: parseMethodOpts(c.options.methodOpt),
170
+ });
171
+ tokenSymbol = pluginResult.tokenSymbol;
172
+ tokenDecimals = pluginResult.tokenDecimals;
173
+ explorerUrl = pluginResult.explorerUrl;
174
+ }
175
+ const confirmEnabled = c.options.silent ? false : c.options.confirm;
176
+ // Display challenge
177
+ const shownKeys = new Set();
178
+ {
179
+ printResponseHeaders(challengeResponse, headerOpts);
180
+ const challengeRows = (() => {
181
+ const skip = new Set(['id', 'request']);
182
+ const rows = [];
183
+ for (const [key, value] of Object.entries(challenge)) {
184
+ if (skip.has(key) || value === undefined)
185
+ continue;
186
+ rows.push([key, fmtChallengeValue(key, value)]);
187
+ }
188
+ return rows.sort(([a], [b]) => a.localeCompare(b));
189
+ })();
190
+ const fmtCtx = { tokenSymbol, tokenDecimals, explorerUrl };
191
+ const requestRows = (() => {
192
+ const skip = new Set(['decimals', 'currency', 'methodDetails']);
193
+ const rows = [];
194
+ for (const [key, value] of Object.entries(challenge.request)) {
195
+ if (skip.has(key) || value === undefined)
196
+ continue;
197
+ rows.push([key, fmtRequestValue(key, value, fmtCtx)]);
198
+ }
199
+ return rows.sort(([a], [b]) => a.localeCompare(b));
200
+ })();
201
+ const detailRows = (() => {
202
+ const methodDetails = challenge.request.methodDetails;
203
+ if (!methodDetails)
204
+ return [];
205
+ const rows = [];
206
+ for (const [key, value] of Object.entries(methodDetails)) {
207
+ if (value === undefined)
208
+ continue;
209
+ if (key === 'memo' && typeof value === 'string') {
210
+ const decoded = decodeMemo(value);
211
+ rows.push([key, decoded ? `${decoded}\n${pc.dim(value)}` : value]);
212
+ }
213
+ else {
214
+ rows.push([key, fmtRequestValue(key, value, fmtCtx)]);
215
+ }
216
+ }
217
+ return rows.sort(([a], [b]) => a.localeCompare(b));
218
+ })();
219
+ const sections = [
220
+ ['Challenge', challengeRows],
221
+ ['Request', requestRows],
222
+ ...(detailRows.length ? [['Details', detailRows]] : []),
223
+ ];
224
+ for (const [, rows] of sections)
225
+ for (const [key] of rows)
226
+ shownKeys.add(key);
227
+ const pad = Math.max(...sections.flatMap(([, rows]) => rows.map(([k]) => k.length)));
228
+ const indent = ` ${''.padEnd(pad)} `;
229
+ if (c.options.verbose >= 1 || confirmEnabled) {
230
+ info(`${pc.bold(pc.yellow('Payment Required'))}\n`);
231
+ for (const [title, rows] of sections) {
232
+ info(`${pc.bold(title)}\n`);
233
+ for (const [label, value] of rows) {
234
+ const [first, ...rest] = value.split('\n');
235
+ info(` ${pc.dim(label.padEnd(pad))} ${first}\n`);
236
+ for (const line of rest)
237
+ info(`${indent}${line}\n`);
238
+ }
239
+ }
240
+ }
241
+ if (confirmEnabled) {
242
+ info('\n');
243
+ const ok = await confirm(`Proceed with ${challenge.intent}?`, true);
244
+ if (!ok) {
245
+ info('Aborted.\n');
246
+ return;
247
+ }
248
+ }
249
+ }
250
+ // Create credential
251
+ let credential;
252
+ if (pluginResult?.createCredential)
253
+ credential = await pluginResult.createCredential(challengeResponse);
254
+ else if (pluginResult) {
255
+ const mppx = Mppx.create({ methods: pluginResult.methods, polyfill: false });
256
+ credential = await mppx.createCredential(challengeResponse, pluginResult.credentialContext);
257
+ }
258
+ else if (configMethod) {
259
+ const mppx = Mppx.create({ methods: [configMethod], polyfill: false });
260
+ credential = await mppx.createCredential(challengeResponse);
261
+ }
262
+ else {
263
+ return c.error({
264
+ code: 'UNSUPPORTED_METHOD',
265
+ message: `Unsupported payment method: ${challenge.method}/${challenge.intent}. Add it to mppx.config.ts using defineConfig().`,
266
+ exitCode: 2,
267
+ });
268
+ }
269
+ // Send credential and get response
270
+ const credentialHeaders = {
271
+ ...init.headers,
272
+ Authorization: credential,
273
+ };
274
+ plugin?.prepareCredentialRequest?.({ challenge, credential, headers: credentialHeaders });
275
+ const credentialFetchInit = { ...init, headers: credentialHeaders };
276
+ if (c.options.verbose >= 2)
277
+ printRequestHeaders(url, credentialFetchInit, info);
278
+ const credentialResponse = await globalThis.fetch(fetchUrl, credentialFetchInit);
279
+ if (c.options.fail && credentialResponse.status >= 400)
280
+ return c.error({
281
+ code: 'HTTP_ERROR',
282
+ message: `HTTP error ${credentialResponse.status}`,
283
+ exitCode: 22,
284
+ });
285
+ if (credentialResponse.status === 402) {
286
+ const body = await credentialResponse.text();
287
+ info(`${pc.bold(pc.red('Payment Rejected'))}\n`);
288
+ try {
289
+ const problem = JSON.parse(body);
290
+ const rows = [];
291
+ for (const [key, value] of Object.entries(problem)) {
292
+ if (value === undefined)
293
+ continue;
294
+ rows.push([key, String(value)]);
295
+ }
296
+ rows.sort(([a], [b]) => a.localeCompare(b));
297
+ const pad = Math.max(...rows.map(([k]) => k.length));
298
+ for (const [label, value] of rows)
299
+ info(` ${pc.dim(label.padEnd(pad))} ${value}\n`);
300
+ }
301
+ catch {
302
+ if (body)
303
+ info(` ${body}\n`);
304
+ }
305
+ return c.error({ code: 'PAYMENT_REJECTED', message: 'Payment rejected', exitCode: 75 });
306
+ }
307
+ printResponseHeaders(credentialResponse, headerOpts);
308
+ // Let plugin own the response lifecycle if it wants to
309
+ const handled = await plugin?.handleResponse?.({
310
+ challenge,
311
+ credential,
312
+ response: credentialResponse,
313
+ fetchUrl,
314
+ fetchInit: init,
315
+ silent: c.options.silent,
316
+ verbose: c.options.verbose,
317
+ confirmEnabled,
318
+ confirm,
319
+ tokenSymbol,
320
+ tokenDecimals,
321
+ explorerUrl,
322
+ shownKeys,
323
+ });
324
+ if (!handled) {
325
+ // Default: print receipt + body
326
+ const receiptHeader = credentialResponse.headers.get('Payment-Receipt');
327
+ if (receiptHeader && c.options.verbose >= 1) {
328
+ try {
329
+ const receiptJson = JSON.parse(Base64.toString(receiptHeader));
330
+ info(`\n${pc.bold(pc.green('Payment Receipt'))}\n`);
331
+ const rows = [];
332
+ const channelId = receiptJson.channelId;
333
+ const reference = receiptJson.reference;
334
+ const skipReference = channelId && reference && channelId === reference;
335
+ const receiptBalanceKeys = new Set(['acceptedCumulative', 'spent']);
336
+ for (const [key, value] of Object.entries(receiptJson)) {
337
+ if (value === undefined || shownKeys.has(key))
338
+ continue;
339
+ if (key === 'reference' && skipReference)
340
+ continue;
341
+ const formatted = plugin?.formatReceiptField?.(key, value);
342
+ if (formatted !== undefined) {
343
+ rows.push([key, formatted]);
344
+ }
345
+ else if (receiptBalanceKeys.has(key) && typeof value === 'string') {
346
+ rows.push([
347
+ key,
348
+ `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`,
349
+ ]);
350
+ }
351
+ else if ((key === 'reference' || key === 'txHash') &&
352
+ typeof value === 'string' &&
353
+ explorerUrl) {
354
+ rows.push([key, link(`${explorerUrl}/tx/${value}`, value)]);
355
+ }
356
+ else
357
+ rows.push([key, String(value)]);
358
+ }
359
+ rows.sort(([a], [b]) => a.localeCompare(b));
360
+ const pad = Math.max(...rows.map(([k]) => k.length));
361
+ for (const [label, value] of rows)
362
+ info(` ${pc.dim(label.padEnd(pad))} ${value}\n`);
363
+ info('\n');
364
+ }
365
+ catch { }
366
+ }
367
+ const body = (await credentialResponse.text()).replace(/\n+$/, '');
368
+ console.log(body);
369
+ }
370
+ }
371
+ catch (err) {
372
+ // Re-throw IncurError so incur's error handler formats it properly
373
+ if (err instanceof Errors.IncurError)
374
+ throw err;
375
+ // TODO: revert cast when https://github.com/wevm/zile/pull/26 is merged
376
+ const errCause = err instanceof Error ? err.cause : undefined;
377
+ const cause = errCause instanceof Error ? errCause : undefined;
378
+ if (cause && 'code' in cause) {
379
+ const code = cause.code;
380
+ if (code === 'ENOTFOUND')
381
+ return c.error({
382
+ code: 'DNS_ERROR',
383
+ message: `Could not resolve host "${hostname}". Check the URL and try again.`,
384
+ exitCode: 6,
385
+ });
386
+ else if (code === 'ECONNREFUSED')
387
+ return c.error({
388
+ code: 'CONNECTION_REFUSED',
389
+ message: `Connection refused by "${hostname}". Is the server running?`,
390
+ retryable: true,
391
+ exitCode: 7,
392
+ });
393
+ else if (code === 'ECONNRESET')
394
+ return c.error({
395
+ code: 'CONNECTION_RESET',
396
+ message: `Connection to "${hostname}" was reset.`,
397
+ retryable: true,
398
+ exitCode: 56,
399
+ });
400
+ else if (code === 'ETIMEDOUT')
401
+ return c.error({
402
+ code: 'CONNECTION_TIMEOUT',
403
+ message: `Connection to "${hostname}" timed out.`,
404
+ retryable: true,
405
+ exitCode: 28,
406
+ });
407
+ else if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
408
+ return c.error({
409
+ code: 'TLS_ERROR',
410
+ message: `TLS certificate error for "${hostname}". Use --insecure to skip verification.`,
411
+ exitCode: 60,
412
+ });
413
+ else
414
+ return c.error({
415
+ code: 'REQUEST_FAILED',
416
+ message: `Request to "${hostname}" failed: ${cause.message}`,
417
+ });
418
+ }
419
+ else {
420
+ const msg = err instanceof Error ? err.message : String(err);
421
+ return c.error({
422
+ code: 'REQUEST_FAILED',
423
+ message: cause
424
+ ? `Request failed: ${msg} (Cause: ${cause.message})`
425
+ : `Request failed: ${msg}`,
426
+ });
427
+ }
428
+ }
429
+ },
430
+ });
431
+ const account = Cli.create('account', {
432
+ description: 'Manage accounts (create, default, delete, fund, list, view)',
433
+ })
434
+ .command('create', {
435
+ description: 'Create new account',
436
+ options: z.object({
437
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
438
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
439
+ }),
440
+ alias: { account: 'a', rpcUrl: 'r' },
441
+ async run(c) {
442
+ let resolvedName = c.options.account;
443
+ if (!resolvedName) {
444
+ const existing = await createKeychain().list();
445
+ if (existing.length === 0)
446
+ resolvedName = 'main';
447
+ else {
448
+ const input = await prompt('Account name');
449
+ if (!input)
450
+ return;
451
+ resolvedName = input;
452
+ }
453
+ }
454
+ let keychain = createKeychain(resolvedName);
455
+ while (await keychain.get()) {
456
+ process.stderr.write(`${pc.dim(`Account "${resolvedName}" already exists.`)}\n\n`);
457
+ const input = await prompt('Enter different name');
458
+ if (!input)
459
+ return;
460
+ resolvedName = input;
461
+ keychain = createKeychain(resolvedName);
462
+ }
463
+ const privateKey = generatePrivateKey();
464
+ const acct = privateKeyToAccount(privateKey);
465
+ await keychain.set(privateKey);
466
+ const accounts = await createKeychain().list();
467
+ if (accounts.length === 1)
468
+ createDefaultStore().set(resolvedName);
469
+ console.log(`Account "${resolvedName}" saved to keychain.`);
470
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url;
471
+ const addrDisplay = explorerUrl
472
+ ? link(`${explorerUrl}/address/${acct.address}`, acct.address)
473
+ : acct.address;
474
+ console.log(pc.dim(`Address ${addrDisplay}`));
475
+ resolveChain(c.options)
476
+ .then((chain) => createClient({ chain, transport: http(c.options.rpcUrl) }))
477
+ .then((client) => import('viem/tempo').then(({ Actions }) => Actions.faucet.fund(client, { account: acct }).catch(() => { })));
478
+ },
479
+ })
480
+ .command('default', {
481
+ description: 'Set default account',
482
+ options: z.object({
483
+ account: z.string().describe('Account name'),
484
+ }),
485
+ alias: { account: 'a' },
486
+ async run(c) {
487
+ const accountName = c.options.account;
488
+ if (isTempoAccount(accountName)) {
489
+ const tempoEntry = resolveTempoAccount(accountName);
490
+ if (!tempoEntry) {
491
+ return c.error({
492
+ code: 'ACCOUNT_NOT_FOUND',
493
+ message: `Account "${accountName}" not found. Is Tempo wallet configured?`,
494
+ exitCode: 69,
495
+ });
496
+ }
497
+ createDefaultStore().set(accountName);
498
+ console.log(`Default account set to "${accountName}"`);
499
+ return;
500
+ }
501
+ const key = await createKeychain(accountName).get();
502
+ if (!key) {
503
+ return c.error({
504
+ code: 'ACCOUNT_NOT_FOUND',
505
+ message: `Account "${accountName}" not found.`,
506
+ exitCode: 69,
507
+ });
508
+ }
509
+ createDefaultStore().set(accountName);
510
+ console.log(`Default account set to "${accountName}"`);
511
+ },
512
+ })
513
+ .command('delete', {
514
+ description: 'Delete account',
515
+ options: z.object({
516
+ account: z.string().describe('Account name'),
517
+ yes: z.boolean().optional().describe('DANGER!! Skip confirmation prompts'),
518
+ }),
519
+ alias: { account: 'a' },
520
+ async run(c) {
521
+ const keychain = createKeychain(c.options.account);
522
+ const key = await keychain.get();
523
+ if (!key) {
524
+ return c.error({
525
+ code: 'ACCOUNT_NOT_FOUND',
526
+ message: `Account "${c.options.account}" not found.`,
527
+ exitCode: 69,
528
+ });
529
+ }
530
+ const acct = privateKeyToAccount(key);
531
+ const balanceLines = await fetchBalanceLines(acct.address, { includeTestnet: false });
532
+ if (!c.options.yes) {
533
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url;
534
+ const addrDisplay = explorerUrl
535
+ ? link(`${explorerUrl}/address/${acct.address}`, acct.address)
536
+ : acct.address;
537
+ process.stderr.write(pc.dim(`Delete account "${c.options.account}"\n`));
538
+ process.stderr.write(pc.dim(` Address ${addrDisplay}\n`));
539
+ for (let i = 0; i < balanceLines.length; i++)
540
+ process.stderr.write(pc.dim(` ${i === 0 ? 'Balance' : ' '} ${balanceLines[i]}\n`));
541
+ process.stderr.write(pc.dim('This action cannot be undone\n\n'));
542
+ const confirmed = await confirm('Confirm delete?');
543
+ if (!confirmed) {
544
+ console.log('Canceled');
545
+ return;
546
+ }
547
+ }
548
+ await keychain.delete();
549
+ const currentDefault = createDefaultStore().get();
550
+ if (currentDefault === c.options.account) {
551
+ const remaining = await createKeychain().list();
552
+ if (remaining.length > 0) {
553
+ createDefaultStore().set(remaining[0]);
554
+ console.log(`Default account set to "${remaining[0]}"`);
555
+ }
556
+ else {
557
+ createDefaultStore().clear();
558
+ }
559
+ }
560
+ console.log(`Account "${c.options.account}" deleted`);
561
+ },
562
+ })
563
+ .command('fund', {
564
+ description: 'Fund account with testnet tokens',
565
+ options: z.object({
566
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
567
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
568
+ }),
569
+ alias: { account: 'a', rpcUrl: 'r' },
570
+ async run(c) {
571
+ const accountName = resolveAccountName(c.options.account);
572
+ const keychain = createKeychain(accountName);
573
+ const key = await keychain.get();
574
+ if (!key) {
575
+ if (c.options.account)
576
+ return c.error({
577
+ code: 'ACCOUNT_NOT_FOUND',
578
+ message: `Account "${accountName}" not found.`,
579
+ exitCode: 69,
580
+ });
581
+ else
582
+ return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 });
583
+ }
584
+ const acct = privateKeyToAccount(key);
585
+ const chain = await resolveChain(c.options);
586
+ const client = createClient({ chain, transport: http(c.options.rpcUrl) });
587
+ console.log(`Funding "${accountName}" on ${chainName(chain)}`);
588
+ try {
589
+ const { Actions } = await import('viem/tempo');
590
+ const hashes = await Actions.faucet.fund(client, { account: acct });
591
+ const explorerUrl = chain.blockExplorers?.default?.url;
592
+ for (const hash of hashes) {
593
+ const label = explorerUrl ? link(`${explorerUrl}/tx/${hash}`, pc.gray(hash)) : hash;
594
+ console.log(` ${label}`);
595
+ }
596
+ const { waitForTransactionReceipt } = await import('viem/actions');
597
+ await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })));
598
+ console.log('Funded successfully');
599
+ }
600
+ catch (err) {
601
+ console.error('Funding failed:', err instanceof Error ? err.message : err);
602
+ }
603
+ },
604
+ })
605
+ .command('list', {
606
+ description: 'List all accounts',
607
+ async run() {
608
+ const currentDefault = createDefaultStore().get();
609
+ const accounts = (await createKeychain().list()).sort();
610
+ const resolved = [];
611
+ for (const accountName of accounts) {
612
+ const key = await createKeychain(accountName).get();
613
+ if (!key)
614
+ continue;
615
+ resolved.push({
616
+ name: accountName,
617
+ address: privateKeyToAccount(key).address,
618
+ });
619
+ }
620
+ const tempoEntries = readTempoKeystore();
621
+ for (let i = 0; i < tempoEntries.length; i++) {
622
+ const entry = tempoEntries[i];
623
+ const tempoName = i === 0 ? 'tempo:default' : `tempo:${i}`;
624
+ if (entry.wallet_address)
625
+ resolved.push({ name: tempoName, address: entry.wallet_address, source: 'tempo wallet' });
626
+ }
627
+ if (resolved.length === 0) {
628
+ console.log(`No accounts found.`);
629
+ return;
630
+ }
631
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url;
632
+ const maxWidth = Math.max(...resolved.map((e) => e.name.length + (e.name === currentDefault ? 1 : 0)));
633
+ for (const entry of resolved) {
634
+ const isDefault = entry.name === currentDefault;
635
+ const label = isDefault ? `${entry.name}${pc.dim('*')}` : entry.name;
636
+ const width = entry.name.length + (isDefault ? 1 : 0);
637
+ const addrDisplay = explorerUrl
638
+ ? link(`${explorerUrl}/address/${entry.address}`, entry.address)
639
+ : entry.address;
640
+ const sourceLabel = entry.source ? ` ${pc.dim(`(${entry.source})`)}` : '';
641
+ console.log(`${label}${' '.repeat(maxWidth - width + 2)}${pc.dim(addrDisplay)}${sourceLabel}`);
642
+ }
643
+ },
644
+ })
645
+ .command('view', {
646
+ description: 'View account address',
647
+ options: z.object({
648
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
649
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
650
+ }),
651
+ alias: { account: 'a', rpcUrl: 'r' },
652
+ async run(c) {
653
+ const accountName = resolveAccountName(c.options.account);
654
+ if (isTempoAccount(accountName)) {
655
+ const tempoEntry = resolveTempoAccount(accountName);
656
+ if (!tempoEntry) {
657
+ return c.error({
658
+ code: 'ACCOUNT_NOT_FOUND',
659
+ message: `Account "${accountName}" not found. Is Tempo wallet configured?`,
660
+ exitCode: 69,
661
+ });
662
+ }
663
+ const address = tempoEntry.wallet_address;
664
+ const rpcUrl = c.options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined);
665
+ const chain = rpcUrl ? await resolveChain({ rpcUrl }) : tempoMainnet;
666
+ const explorerUrl = chain.blockExplorers?.default?.url;
667
+ const addrDisplay = explorerUrl
668
+ ? link(`${explorerUrl}/address/${address}`, address)
669
+ : address;
670
+ console.log(`${pc.dim('Address')} ${addrDisplay}`);
671
+ const balanceLines = await fetchBalanceLines(address, chain && rpcUrl ? { chain, rpcUrl } : undefined);
672
+ for (let i = 0; i < balanceLines.length; i++)
673
+ console.log(`${pc.dim(i === 0 ? 'Balance' : ' ')} ${balanceLines[i]}`);
674
+ console.log(`${pc.dim('Name')} ${accountName}`);
675
+ console.log(`${pc.dim('Type')} ${tempoEntry.wallet_type} ${pc.dim('(tempo wallet)')}`);
676
+ return;
677
+ }
678
+ const keychain = createKeychain(accountName);
679
+ const key = await keychain.get();
680
+ if (!key) {
681
+ if (c.options.account)
682
+ return c.error({
683
+ code: 'ACCOUNT_NOT_FOUND',
684
+ message: `Account "${accountName}" not found.`,
685
+ exitCode: 69,
686
+ });
687
+ else
688
+ return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 });
689
+ }
690
+ const acct = privateKeyToAccount(key);
691
+ const rpcUrl = c.options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined);
692
+ const chain = rpcUrl ? await resolveChain({ rpcUrl }) : tempoMainnet;
693
+ const explorerUrl = chain.blockExplorers?.default?.url;
694
+ const addrDisplay = explorerUrl
695
+ ? link(`${explorerUrl}/address/${acct.address}`, acct.address)
696
+ : acct.address;
697
+ console.log(`${pc.dim('Address')} ${addrDisplay}`);
698
+ const balanceLines = await fetchBalanceLines(acct.address, chain && rpcUrl ? { chain, rpcUrl } : undefined);
699
+ for (let i = 0; i < balanceLines.length; i++)
700
+ console.log(`${pc.dim(i === 0 ? 'Balance' : ' ')} ${balanceLines[i]}`);
701
+ console.log(`${pc.dim('Name')} ${accountName}`);
702
+ },
703
+ });
704
+ const sign = Cli.create('sign', {
705
+ description: 'Sign a payment challenge and output the Authorization header',
706
+ usage: [
707
+ { suffix: '--challenge <value> [options]' },
708
+ { prefix: 'echo <challenge> |', suffix: '[options]' },
709
+ ],
710
+ options: z.object({
711
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
712
+ challenge: z.string().optional().describe('WWW-Authenticate challenge value'),
713
+ config: z.string().optional().describe('Path to config file'),
714
+ dryRun: z.boolean().optional().describe('Validate and parse the challenge without signing'),
715
+ methodOpt: z
716
+ .array(z.string())
717
+ .optional()
718
+ .describe('Method-specific option (key=value, repeatable)'),
719
+ rpcUrl: z
720
+ .string()
721
+ .optional()
722
+ .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
723
+ }),
724
+ alias: {
725
+ account: 'a',
726
+ challenge: 'C',
727
+ config: 'c',
728
+ methodOpt: 'M',
729
+ rpcUrl: 'r',
730
+ },
731
+ async run(c) {
732
+ const raw = c.options.challenge ||
733
+ (process.stdin.isTTY === false
734
+ ? await new Promise((resolve, reject) => {
735
+ let data = '';
736
+ process.stdin.setEncoding('utf-8');
737
+ process.stdin.on('data', (chunk) => {
738
+ data += chunk;
739
+ });
740
+ process.stdin.on('end', () => resolve(data.trim()));
741
+ process.stdin.on('error', reject);
742
+ })
743
+ : undefined);
744
+ if (!raw) {
745
+ return c.error({
746
+ code: 'NO_CHALLENGE',
747
+ message: 'No challenge provided. Use --challenge or pipe via stdin.',
748
+ exitCode: 2,
749
+ });
750
+ }
751
+ let challenge;
752
+ try {
753
+ challenge = Challenge.deserialize(raw);
754
+ }
755
+ catch (err) {
756
+ return c.error({
757
+ code: 'INVALID_CHALLENGE',
758
+ message: `Failed to parse challenge: ${err instanceof Error ? err.message : err}`,
759
+ exitCode: 2,
760
+ });
761
+ }
762
+ if (c.options.dryRun) {
763
+ process.stderr.write('Challenge is valid.\n');
764
+ return;
765
+ }
766
+ const loaded = await loadConfig(c.options.config);
767
+ const { plugin, method: configMethod } = resolvePlugin(challenge, loaded?.config);
768
+ const methodOpts = parseMethodOpts(c.options.methodOpt);
769
+ const wwwAuth = Challenge.serialize(challenge);
770
+ const fakeResponse = new Response(null, {
771
+ status: 402,
772
+ headers: { 'WWW-Authenticate': wwwAuth },
773
+ });
774
+ let credential;
775
+ if (plugin) {
776
+ const result = await plugin.setup({
777
+ challenge,
778
+ options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
779
+ methodOpts,
780
+ });
781
+ if (result.createCredential) {
782
+ credential = await result.createCredential(fakeResponse);
783
+ }
784
+ else {
785
+ const mppx = Mppx.create({ methods: result.methods, polyfill: false });
786
+ credential = await mppx.createCredential(fakeResponse, result.credentialContext);
787
+ }
788
+ }
789
+ else if (configMethod) {
790
+ const mppx = Mppx.create({ methods: [configMethod], polyfill: false });
791
+ credential = await mppx.createCredential(fakeResponse);
792
+ }
793
+ else {
794
+ return c.error({
795
+ code: 'UNSUPPORTED_METHOD',
796
+ message: `Unsupported payment method: ${challenge.method}/${challenge.intent}. Add it to mppx.config.ts using defineConfig().`,
797
+ exitCode: 2,
798
+ });
799
+ }
800
+ if (c.format === 'json') {
801
+ console.log(JSON.stringify({ authorization: credential }));
802
+ }
803
+ else {
804
+ console.log(credential);
805
+ }
806
+ },
807
+ });
808
+ const init = Cli.create('init', {
809
+ description: 'Create an mppx.config.ts file in the current directory',
810
+ options: z.object({
811
+ force: z.boolean().optional().describe('Overwrite existing config file'),
812
+ }),
813
+ alias: { force: 'f' },
814
+ async run(c) {
815
+ const cwd = process.cwd();
816
+ // Determine file extension: .ts if tsconfig exists, .mjs if type:module, else .js
817
+ const ext = (() => {
818
+ if (fs.existsSync(path.join(cwd, 'tsconfig.json')))
819
+ return '.ts';
820
+ try {
821
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
822
+ if (pkg.type === 'module')
823
+ return '.mjs';
824
+ }
825
+ catch { }
826
+ return '.js';
827
+ })();
828
+ const filename = `mppx.config${ext}`;
829
+ const dest = path.join(cwd, filename);
830
+ if (fs.existsSync(dest) && !c.options.force) {
831
+ return c.error({
832
+ code: 'CONFIG_EXISTS',
833
+ message: `${filename} already exists. Use --force to overwrite.`,
834
+ exitCode: 1,
835
+ });
836
+ }
837
+ const template = `import { defineConfig } from 'mppx/cli'
838
+
839
+ export default defineConfig({
840
+ methods: [],
841
+ plugins: [],
842
+ })
843
+ `;
844
+ fs.writeFileSync(dest, template);
845
+ console.log(`Created ${filename}`);
846
+ },
847
+ });
848
+ cli.command(account);
849
+ cli.command(init);
850
+ cli.command(sign);
851
+ export default cli;
852
+ //# sourceMappingURL=cli.js.map