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