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
@@ -0,0 +1,842 @@
1
+ import * as child from 'node:child_process'
2
+ import * as fs from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import * as os from 'node:os'
5
+ import * as path from 'node:path'
6
+ import { Errors, z } from 'incur'
7
+ import { Base64 } from 'ox'
8
+ import type { Address } from 'viem'
9
+ import { createClient, http } from 'viem'
10
+ import { privateKeyToAccount } from 'viem/accounts'
11
+ import * as Credential from '../../Credential.js'
12
+ import { tempo as tempoMethods } from '../../tempo/client/index.js'
13
+ import type { SessionCredentialPayload } from '../../tempo/session/Types.js'
14
+ import { signVoucher } from '../../tempo/session/Voucher.js'
15
+ import { createDefaultStore, createKeychain, resolveAccountName } from '../account.js'
16
+ import {
17
+ fetchTokenInfo,
18
+ fmtBalance,
19
+ isTempoAccount,
20
+ isTestnet,
21
+ link,
22
+ pc,
23
+ resolveChain,
24
+ } from '../utils.js'
25
+ import { createPlugin, type Plugin } from './plugin.js'
26
+
27
+ const packageJson = createRequire(import.meta.url)('../../../package.json') as { name: string }
28
+
29
+ export function tempo() {
30
+ let _session:
31
+ | {
32
+ signVoucher(params: {
33
+ channelId: string
34
+ cumulativeAmount: bigint
35
+ escrowContract: Address
36
+ chainId: number
37
+ }): Promise<string>
38
+ source: string
39
+ }
40
+ | undefined
41
+
42
+ return createPlugin({
43
+ method: 'tempo',
44
+
45
+ async setup({ challenge, options, methodOpts }) {
46
+ const accountName = resolveAccountName(options.account)
47
+ const challengeRequest = challenge.request as Record<string, unknown>
48
+ const currency = challengeRequest.currency as string | undefined
49
+
50
+ let tokenSymbol = currency ?? ''
51
+ let tokenDecimals = (challengeRequest.decimals as number | undefined) ?? 6
52
+ let explorerUrl: string | undefined
53
+
54
+ let account: ReturnType<typeof privateKeyToAccount> | undefined
55
+ let client: ReturnType<typeof createClient> | undefined
56
+ let useTempoCliSign = false
57
+
58
+ const privateKey =
59
+ process.env.MPPX_PRIVATE_KEY?.trim() ||
60
+ (isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
61
+
62
+ if (!privateKey && isTempoAccount(accountName) && hasTempoCliSync()) {
63
+ useTempoCliSign = true
64
+ const tempoEntry = resolveTempoAccount(accountName)
65
+ if (tempoEntry) {
66
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
67
+ client = createClient({
68
+ chain: await resolveChain({ rpcUrl }),
69
+ transport: http(rpcUrl),
70
+ })
71
+ explorerUrl = client.chain?.blockExplorers?.default?.url
72
+ const tokenInfo = currency
73
+ ? await fetchTokenInfo(
74
+ client,
75
+ currency as Address,
76
+ tempoEntry.wallet_address as Address,
77
+ ).catch(() => undefined)
78
+ : undefined
79
+ tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
80
+ tokenDecimals =
81
+ tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
82
+ }
83
+ } else if (!privateKey) {
84
+ const fallback = fallbackFromTempo()
85
+ if (fallback) {
86
+ const fallbackKey = await createKeychain(fallback).get()
87
+ if (fallbackKey) account = privateKeyToAccount(fallbackKey as `0x${string}`)
88
+ }
89
+ if (!account) {
90
+ if (options.account)
91
+ throw new Errors.IncurError({
92
+ code: 'ACCOUNT_NOT_FOUND',
93
+ message: `Account "${accountName}" not found.`,
94
+ exitCode: 69,
95
+ })
96
+ else
97
+ throw new Errors.IncurError({
98
+ code: 'ACCOUNT_NOT_FOUND',
99
+ message: 'No account found.',
100
+ exitCode: 69,
101
+ })
102
+ }
103
+ } else account = privateKeyToAccount(privateKey as `0x${string}`)
104
+
105
+ if (!useTempoCliSign && account) {
106
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
107
+ client = createClient({
108
+ chain: await resolveChain({ rpcUrl }),
109
+ transport: http(rpcUrl),
110
+ })
111
+ explorerUrl = client.chain?.blockExplorers?.default?.url
112
+ const tokenInfo = currency
113
+ ? await fetchTokenInfo(client, currency as Address, account.address).catch(
114
+ () => undefined,
115
+ )
116
+ : undefined
117
+ tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
118
+ tokenDecimals =
119
+ tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
120
+ }
121
+
122
+ if (useTempoCliSign)
123
+ return {
124
+ tokenSymbol,
125
+ tokenDecimals,
126
+ explorerUrl,
127
+ methods: [],
128
+ async createCredential(response: Response) {
129
+ const wwwAuth = response.headers.get('www-authenticate')
130
+ if (!wwwAuth) throw new Error('No WWW-Authenticate header in 402 response.')
131
+ return tempoCliSign(wwwAuth)
132
+ },
133
+ }
134
+
135
+ if (!account || !client)
136
+ throw new Errors.IncurError({
137
+ code: 'ACCOUNT_NOT_FOUND',
138
+ message: 'Tempo requires a configured account.',
139
+ exitCode: 69,
140
+ })
141
+
142
+ const tempoOpts = parseOptions(
143
+ z.object({
144
+ channel: z.optional(z.coerce.string()),
145
+ deposit: z.optional(z.union([z.string(), z.number()])),
146
+ }),
147
+ methodOpts,
148
+ )
149
+
150
+ const methods = tempoMethods({
151
+ account,
152
+ getClient: () => client!,
153
+ deposit: (() => {
154
+ if (challenge.intent !== 'session') return undefined
155
+ const suggestedDeposit = (challenge.request as Record<string, unknown>)
156
+ .suggestedDeposit as string | undefined
157
+ const cliDeposit = tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
158
+ const resolved =
159
+ suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
160
+ if (!resolved) {
161
+ throw new Errors.IncurError({
162
+ code: 'MISSING_DEPOSIT',
163
+ message:
164
+ 'Session payment requires a deposit. Use -M deposit=<amount> or connect to testnet.',
165
+ exitCode: 2,
166
+ })
167
+ }
168
+ return resolved
169
+ })(),
170
+ })
171
+
172
+ const credentialContext = (() => {
173
+ if (!tempoOpts.channel) return undefined
174
+ const channelId = tempoOpts.channel
175
+ const saved = readChannelCumulative(channelId)
176
+ return {
177
+ channelId,
178
+ ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
179
+ }
180
+ })()
181
+
182
+ const chainId = client.chain!.id
183
+
184
+ // Store session support for use in lifecycle hooks
185
+ _session = {
186
+ async signVoucher({ channelId, cumulativeAmount, escrowContract, chainId }) {
187
+ return Credential.serialize({
188
+ challenge,
189
+ payload: {
190
+ action: 'voucher',
191
+ channelId,
192
+ cumulativeAmount: cumulativeAmount.toString(),
193
+ signature: await signVoucher(
194
+ client!,
195
+ account!,
196
+ { channelId: channelId as `0x${string}`, cumulativeAmount },
197
+ escrowContract,
198
+ chainId,
199
+ ),
200
+ },
201
+ source: `did:pkh:eip155:${chainId}:${account!.address}`,
202
+ })
203
+ },
204
+ source: `did:pkh:eip155:${chainId}:${account.address}`,
205
+ }
206
+
207
+ return {
208
+ tokenSymbol,
209
+ tokenDecimals,
210
+ explorerUrl,
211
+ methods: [...methods],
212
+ credentialContext,
213
+ }
214
+ },
215
+
216
+ prepareCredentialRequest({ challenge, headers }) {
217
+ if (challenge.intent === 'session') headers.Accept = 'text/event-stream'
218
+ },
219
+
220
+ async handleResponse(ctx) {
221
+ if (ctx.challenge.intent !== 'session') return false
222
+ if (!_session) return false
223
+
224
+ const { challenge, credential, response, fetchUrl, fetchInit, verbose } = ctx
225
+ const { silent, confirmEnabled, tokenSymbol, tokenDecimals, explorerUrl, shownKeys } = ctx
226
+ const info = silent ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg)
227
+
228
+ const parsed = Credential.deserialize<SessionCredentialPayload>(credential)
229
+ const challengeRequest = challenge.request as Record<string, unknown>
230
+ const sessionMd = challengeRequest.methodDetails as
231
+ | { escrowContract?: string; chainId?: number }
232
+ | undefined
233
+ const channelId = parsed.payload.channelId
234
+ const escrowContract = sessionMd?.escrowContract as Address | undefined
235
+ const chainId = sessionMd?.chainId ?? 0
236
+ let cumulativeAmount =
237
+ 'cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount
238
+ ? BigInt(parsed.payload.cumulativeAmount)
239
+ : 0n
240
+
241
+ if (verbose >= 1) {
242
+ if (parsed.payload.action === 'open') {
243
+ const depositRaw = challengeRequest.suggestedDeposit as string | undefined
244
+ const depositDisplay = depositRaw
245
+ ? ` ${pc.dim(`(deposit ${depositRaw} ${tokenSymbol})`)}`
246
+ : ''
247
+ const prefix = confirmEnabled ? '' : '\n'
248
+ info(
249
+ `${prefix}${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`,
250
+ )
251
+ } else {
252
+ const prefix = confirmEnabled ? '' : '\n'
253
+ info(`${prefix}${pc.dim(`Channel reused ${parsed.payload.channelId}`)}\n`)
254
+ }
255
+ }
256
+
257
+ // Handle non-SSE session response (server returned non-streaming)
258
+ let credentialResponse = response
259
+ if (
260
+ credentialResponse.ok &&
261
+ !credentialResponse.headers.get('Content-Type')?.includes('text/event-stream')
262
+ ) {
263
+ if (parsed.payload.action === 'open' && 'cumulativeAmount' in parsed.payload) {
264
+ const tickAmount = BigInt(challengeRequest.amount as string)
265
+ cumulativeAmount = BigInt(parsed.payload.cumulativeAmount) + tickAmount
266
+
267
+ if (escrowContract) {
268
+ const voucherCred = await _session.signVoucher({
269
+ channelId,
270
+ cumulativeAmount,
271
+ escrowContract,
272
+ chainId,
273
+ })
274
+ credentialResponse = await globalThis.fetch(fetchUrl, {
275
+ ...fetchInit,
276
+ headers: {
277
+ ...(fetchInit.headers as Record<string, string>),
278
+ Accept: 'text/event-stream',
279
+ Authorization: voucherCred,
280
+ },
281
+ })
282
+ }
283
+ }
284
+ }
285
+
286
+ // Print receipt from initial response headers
287
+ const receiptHeader = credentialResponse.headers.get('Payment-Receipt')
288
+ if (receiptHeader) {
289
+ try {
290
+ const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record<string, unknown>
291
+ if (
292
+ typeof receiptJson.acceptedCumulative === 'string' &&
293
+ receiptJson.acceptedCumulative
294
+ ) {
295
+ cumulativeAmount = BigInt(receiptJson.acceptedCumulative)
296
+ writeChannelCumulative(channelId, cumulativeAmount)
297
+ }
298
+ if (verbose >= 1)
299
+ printReceipt(receiptJson, {
300
+ info,
301
+ shownKeys,
302
+ tokenSymbol,
303
+ tokenDecimals,
304
+ explorerUrl,
305
+ handler: this,
306
+ prefix: '\n',
307
+ })
308
+ } catch {}
309
+ }
310
+
311
+ const contentType = credentialResponse.headers.get('Content-Type') ?? ''
312
+ if (contentType.includes('text/event-stream')) {
313
+ await handleSseStream(credentialResponse, {
314
+ challenge,
315
+ channelId,
316
+ escrowContract,
317
+ chainId,
318
+ cumulativeAmount,
319
+ fetchUrl,
320
+ fetchInit,
321
+ session: _session,
322
+ info,
323
+ verbose,
324
+ shownKeys,
325
+ tokenSymbol,
326
+ tokenDecimals,
327
+ explorerUrl,
328
+ handler: this,
329
+ })
330
+ } else {
331
+ // Non-SSE: print body, then close channel
332
+ const body = (await credentialResponse.text()).replace(/\n+$/, '')
333
+ console.log(body)
334
+
335
+ if (channelId && escrowContract && chainId) {
336
+ if (confirmEnabled) info('\n')
337
+ if (confirmEnabled && !(await ctx.confirm('Close channel?', true))) {
338
+ if (verbose >= 1) info(`${pc.dim('Kept channel open.')}\n`)
339
+ } else {
340
+ await closeChannel({
341
+ channelId,
342
+ cumulativeAmount,
343
+ escrowContract,
344
+ chainId,
345
+ fetchUrl,
346
+ fetchInit,
347
+ session: _session,
348
+ info,
349
+ verbose,
350
+ tokenSymbol,
351
+ tokenDecimals,
352
+ explorerUrl,
353
+ confirmEnabled,
354
+ })
355
+ }
356
+ }
357
+ }
358
+
359
+ return true
360
+ },
361
+
362
+ formatReceiptField(key, value) {
363
+ if (
364
+ (key === 'reference' || key === 'txHash') &&
365
+ typeof value === 'string' &&
366
+ value.startsWith('0x')
367
+ )
368
+ return undefined // let default explorer link handling apply
369
+ },
370
+ })
371
+ }
372
+
373
+ // --- Session helpers ---
374
+
375
+ function printReceipt(
376
+ receiptJson: Record<string, unknown>,
377
+ opts: {
378
+ info: (msg: string) => void
379
+ shownKeys: Set<string>
380
+ tokenSymbol: string
381
+ tokenDecimals: number
382
+ explorerUrl?: string | undefined
383
+ handler: Plugin
384
+ prefix?: string | undefined
385
+ },
386
+ ) {
387
+ opts.info(`${opts.prefix ?? ''}${pc.bold(pc.green('Payment Receipt'))}\n`)
388
+ const rows: [string, string][] = []
389
+ const skipRef =
390
+ receiptJson.channelId &&
391
+ receiptJson.reference &&
392
+ receiptJson.channelId === receiptJson.reference
393
+ const receiptBalanceKeys = new Set(['acceptedCumulative', 'spent'])
394
+ for (const [key, value] of Object.entries(receiptJson)) {
395
+ if (value === undefined || opts.shownKeys.has(key)) continue
396
+ if (key === 'reference' && skipRef) continue
397
+ const formatted = opts.handler.formatReceiptField?.(key, value)
398
+ if (formatted !== undefined) {
399
+ rows.push([key, formatted])
400
+ } else if (receiptBalanceKeys.has(key) && typeof value === 'string') {
401
+ rows.push([
402
+ key,
403
+ `${value} ${pc.dim(`(${fmtBalance(BigInt(value), opts.tokenSymbol, opts.tokenDecimals)})`)}`,
404
+ ])
405
+ } else if (
406
+ (key === 'reference' || key === 'txHash') &&
407
+ typeof value === 'string' &&
408
+ opts.explorerUrl
409
+ ) {
410
+ rows.push([key, link(`${opts.explorerUrl}/tx/${value}`, value)])
411
+ } else rows.push([key, String(value)])
412
+ }
413
+ rows.sort(([a], [b]) => a.localeCompare(b))
414
+ const pad = Math.max(...rows.map(([k]) => k.length))
415
+ for (const [label, value] of rows) opts.info(` ${pc.dim(label.padEnd(pad))} ${value}\n`)
416
+ if (opts.prefix) opts.info('\n')
417
+ }
418
+
419
+ async function handleSseStream(
420
+ response: Response,
421
+ opts: {
422
+ challenge: import('../../Challenge.js').Challenge
423
+ channelId: string
424
+ escrowContract: Address | undefined
425
+ chainId: number
426
+ cumulativeAmount: bigint
427
+ fetchUrl: string
428
+ fetchInit: RequestInit
429
+ session: {
430
+ signVoucher(params: {
431
+ channelId: string
432
+ cumulativeAmount: bigint
433
+ escrowContract: Address
434
+ chainId: number
435
+ }): Promise<string>
436
+ }
437
+ info: (msg: string) => void
438
+ verbose: number
439
+ shownKeys: Set<string>
440
+ tokenSymbol: string
441
+ tokenDecimals: number
442
+ explorerUrl?: string | undefined
443
+ handler: Plugin
444
+ },
445
+ ) {
446
+ let cumulativeAmount = opts.cumulativeAmount
447
+
448
+ const reader = response.body?.getReader()
449
+ if (!reader) throw new Error('No response body')
450
+
451
+ const decoder = new TextDecoder()
452
+ let buffer = ''
453
+ let currentEvent = ''
454
+
455
+ const termBg = opts.verbose ? await detectTerminalBg() : undefined
456
+ const chunkBgs = (() => {
457
+ if (!termBg || !pc.isColorSupported) return undefined
458
+ const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
459
+ const isDark = 0.299 * termBg.r + 0.587 * termBg.g + 0.114 * termBg.b < 128
460
+ const offset = isDark ? 1 : -1
461
+ const bgRgb = (d: number) => (s: string) => {
462
+ const r = clamp(termBg.r + d * offset)
463
+ const g = clamp(termBg.g + d * offset)
464
+ const b = clamp(termBg.b + d * offset)
465
+ return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`
466
+ }
467
+ return [bgRgb(12), bgRgb(24)] as const
468
+ })()
469
+ let chunkIdx = 0
470
+
471
+ const writeContent = (chunk: string) => {
472
+ if (chunkBgs) {
473
+ const bgFn = chunkBgs[chunkIdx % chunkBgs.length]!
474
+ process.stdout.write(chunk.replace(/[^\n]+/g, (m) => bgFn(m)))
475
+ chunkIdx++
476
+ } else {
477
+ process.stdout.write(chunk)
478
+ }
479
+ }
480
+
481
+ const processLines = async (lines: string[]) => {
482
+ for (const line of lines) {
483
+ if (line.startsWith('event: ')) {
484
+ currentEvent = line.slice(7).trim()
485
+ continue
486
+ }
487
+ if (!line.startsWith('data: ')) {
488
+ if (line === '') currentEvent = ''
489
+ continue
490
+ }
491
+ const data = line.slice(6)
492
+ if (data.trim() === '[DONE]') continue
493
+ if (
494
+ currentEvent === 'payment-need-voucher' &&
495
+ opts.channelId &&
496
+ opts.escrowContract &&
497
+ opts.chainId
498
+ ) {
499
+ try {
500
+ const event = JSON.parse(data) as {
501
+ channelId: string
502
+ requiredCumulative: string
503
+ }
504
+ const required = BigInt(event.requiredCumulative)
505
+ cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
506
+
507
+ const voucherCred = await opts.session.signVoucher({
508
+ channelId: opts.channelId,
509
+ cumulativeAmount,
510
+ escrowContract: opts.escrowContract,
511
+ chainId: opts.chainId,
512
+ })
513
+ await globalThis.fetch(opts.fetchUrl, {
514
+ method: 'POST',
515
+ headers: { Authorization: voucherCred },
516
+ })
517
+ } catch (e) {
518
+ opts.info(pc.dim(pc.yellow(` [voucher failed: ${e instanceof Error ? e.message : e}]`)))
519
+ }
520
+ currentEvent = ''
521
+ continue
522
+ }
523
+ if (currentEvent === 'payment-receipt') {
524
+ if (opts.verbose >= 1) {
525
+ try {
526
+ const receipt = JSON.parse(data) as Record<string, unknown>
527
+ printReceipt(receipt, {
528
+ info: opts.info,
529
+ shownKeys: opts.shownKeys,
530
+ tokenSymbol: opts.tokenSymbol,
531
+ tokenDecimals: opts.tokenDecimals,
532
+ explorerUrl: opts.explorerUrl,
533
+ handler: opts.handler,
534
+ prefix: '\n\n',
535
+ })
536
+ } catch {}
537
+ }
538
+ currentEvent = ''
539
+ continue
540
+ }
541
+ if (data.length === 0) {
542
+ writeContent('\n')
543
+ } else {
544
+ try {
545
+ const parsed = JSON.parse(data) as {
546
+ token?: string
547
+ choices?: { delta?: { content?: string } }[]
548
+ }
549
+ writeContent(parsed.token ?? parsed.choices?.[0]?.delta?.content ?? data)
550
+ } catch {
551
+ writeContent(data)
552
+ }
553
+ }
554
+ currentEvent = ''
555
+ }
556
+ }
557
+
558
+ while (true) {
559
+ const { done, value } = await reader.read()
560
+ if (done) break
561
+ buffer += decoder.decode(value, { stream: true })
562
+ const lines = buffer.split('\n')
563
+ buffer = lines.pop()!
564
+ await processLines(lines)
565
+ }
566
+ if (buffer.trim()) await processLines([buffer])
567
+
568
+ // Close channel after SSE stream ends
569
+ if (opts.channelId && opts.escrowContract && opts.chainId) {
570
+ await closeChannel({
571
+ channelId: opts.channelId,
572
+ cumulativeAmount,
573
+ escrowContract: opts.escrowContract,
574
+ chainId: opts.chainId,
575
+ fetchUrl: opts.fetchUrl,
576
+ fetchInit: opts.fetchInit,
577
+ session: opts.session,
578
+ info: opts.info,
579
+ verbose: opts.verbose,
580
+ tokenSymbol: opts.tokenSymbol,
581
+ tokenDecimals: opts.tokenDecimals,
582
+ explorerUrl: opts.explorerUrl,
583
+ confirmEnabled: false,
584
+ })
585
+ }
586
+ }
587
+
588
+ async function closeChannel(opts: {
589
+ channelId: string
590
+ cumulativeAmount: bigint
591
+ escrowContract: Address
592
+ chainId: number
593
+ fetchUrl: string
594
+ fetchInit: RequestInit
595
+ session: {
596
+ signVoucher(params: {
597
+ channelId: string
598
+ cumulativeAmount: bigint
599
+ escrowContract: Address
600
+ chainId: number
601
+ }): Promise<string>
602
+ }
603
+ info: (msg: string) => void
604
+ verbose: number
605
+ tokenSymbol: string
606
+ tokenDecimals: number
607
+ explorerUrl?: string | undefined
608
+ confirmEnabled: boolean
609
+ }) {
610
+ const closeCred = await opts.session.signVoucher({
611
+ channelId: opts.channelId,
612
+ cumulativeAmount: opts.cumulativeAmount,
613
+ escrowContract: opts.escrowContract,
614
+ chainId: opts.chainId,
615
+ })
616
+ const closeRes = await globalThis.fetch(opts.fetchUrl, {
617
+ ...opts.fetchInit,
618
+ headers: {
619
+ ...(opts.fetchInit.headers as Record<string, string>),
620
+ Authorization: closeCred,
621
+ },
622
+ })
623
+ if (closeRes.ok) {
624
+ deleteChannelState(opts.channelId)
625
+ if (opts.verbose >= 1) {
626
+ const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
627
+ let closeTxHash: string | undefined
628
+ if (closeReceiptHeader) {
629
+ try {
630
+ const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<string, unknown>
631
+ if (typeof r.txHash === 'string') closeTxHash = r.txHash
632
+ } catch {}
633
+ }
634
+ const txInfo =
635
+ closeTxHash && opts.explorerUrl
636
+ ? ` ${pc.dim(link(`${opts.explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
637
+ : ''
638
+ opts.info(
639
+ `${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(opts.cumulativeAmount, opts.tokenSymbol, opts.tokenDecimals)}.`)}${txInfo}\n`,
640
+ )
641
+ }
642
+ } else {
643
+ const closeBody = await closeRes.text().catch(() => '')
644
+ opts.info(`\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`)
645
+ opts.info(
646
+ `${pc.dim(` channelId: ${opts.channelId}`)}\n` +
647
+ `${pc.dim(` cumulativeAmount: ${opts.cumulativeAmount}`)}\n` +
648
+ `${pc.dim(` escrowContract: ${opts.escrowContract}`)}\n` +
649
+ `${pc.dim(` chainId: ${opts.chainId}`)}\n` +
650
+ `${pc.dim(` response: ${closeBody || '(empty)'}`)}\n`,
651
+ )
652
+ }
653
+ }
654
+
655
+ function detectTerminalBg(
656
+ timeoutMs = 100,
657
+ ): Promise<{ r: number; g: number; b: number } | undefined> {
658
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(undefined)
659
+ return new Promise((resolve) => {
660
+ const wasRaw = process.stdin.isRaw
661
+ let buf = ''
662
+ const cleanup = () => {
663
+ clearTimeout(timer)
664
+ process.stdin.removeListener('data', onData)
665
+ if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw ?? false)
666
+ process.stdin.pause()
667
+ }
668
+ const timer = setTimeout(() => {
669
+ cleanup()
670
+ resolve(undefined)
671
+ }, timeoutMs)
672
+ const onData = (data: Buffer) => {
673
+ buf += data.toString()
674
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence for terminal background detection
675
+ const match = buf.match(/\x1b\]11;rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i)
676
+ if (!match) return
677
+ cleanup()
678
+ const parse = (hex: string) => Number.parseInt(hex.slice(0, 2), 16)
679
+ resolve({ r: parse(match[1]!), g: parse(match[2]!), b: parse(match[3]!) })
680
+ }
681
+ process.stdin.setRawMode(true)
682
+ process.stdin.resume()
683
+ process.stdin.on('data', onData)
684
+ process.stdout.write('\x1b]11;?\x07')
685
+ })
686
+ }
687
+
688
+ // --- Account helpers ---
689
+
690
+ function parseOptions<const schema extends z.ZodType>(
691
+ schema: schema,
692
+ rawOptions: unknown,
693
+ ): z.output<schema> {
694
+ const result = schema.safeParse(rawOptions ?? {})
695
+ if (result.success) return result.data
696
+ const summary = result.error.issues
697
+ .map((issue) => {
698
+ const path = issue.path.length ? issue.path.join('.') : 'options'
699
+ return `${path}: ${issue.message}`
700
+ })
701
+ .join(', ')
702
+ throw new Error(`Invalid CLI options (${summary})`)
703
+ }
704
+
705
+ function channelStateDir() {
706
+ return path.join(
707
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
708
+ 'mppx',
709
+ 'channels',
710
+ )
711
+ }
712
+
713
+ function readChannelCumulative(channelId: string): bigint | undefined {
714
+ try {
715
+ const raw = fs.readFileSync(path.join(channelStateDir(), channelId), 'utf-8').trim()
716
+ return raw ? BigInt(raw) : undefined
717
+ } catch {
718
+ return undefined
719
+ }
720
+ }
721
+
722
+ function writeChannelCumulative(channelId: string, cumulative: bigint): void {
723
+ const dir = channelStateDir()
724
+ fs.mkdirSync(dir, { recursive: true })
725
+ fs.writeFileSync(path.join(dir, channelId), cumulative.toString(), 'utf-8')
726
+ }
727
+
728
+ function deleteChannelState(channelId: string): void {
729
+ try {
730
+ fs.unlinkSync(path.join(channelStateDir(), channelId))
731
+ } catch {}
732
+ }
733
+
734
+ function tempoKeystorePath(): string {
735
+ const platform = os.platform()
736
+ if (platform === 'darwin')
737
+ return path.join(os.homedir(), 'Library', 'Application Support', 'tempo', 'wallet', 'keys.toml')
738
+ return path.join(
739
+ process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'),
740
+ 'tempo',
741
+ 'wallet',
742
+ 'keys.toml',
743
+ )
744
+ }
745
+
746
+ interface TempoKeyEntry {
747
+ wallet_type: string
748
+ wallet_address: string
749
+ chain_id: number
750
+ }
751
+
752
+ export function readTempoKeystore(): TempoKeyEntry[] {
753
+ try {
754
+ const raw = fs.readFileSync(tempoKeystorePath(), 'utf-8')
755
+ const entries: TempoKeyEntry[] = []
756
+ let current: Partial<TempoKeyEntry> | undefined
757
+ for (const line of raw.split('\n')) {
758
+ const trimmed = line.trim()
759
+ if (trimmed === '[[keys]]') {
760
+ if (current?.wallet_address) entries.push(current as TempoKeyEntry)
761
+ current = { wallet_type: 'local', wallet_address: '', chain_id: 0 }
762
+ continue
763
+ }
764
+ if (!current) continue
765
+ const m = trimmed.match(/^(\w+)\s*=\s*"?([^"]*)"?$/)
766
+ if (!m) continue
767
+ const [, key, value] = m
768
+ if (key === 'wallet_type') current.wallet_type = value!
769
+ else if (key === 'wallet_address') current.wallet_address = value!
770
+ else if (key === 'chain_id') current.chain_id = Number.parseInt(value!, 10)
771
+ }
772
+ if (current?.wallet_address) entries.push(current as TempoKeyEntry)
773
+ return entries
774
+ } catch {
775
+ return []
776
+ }
777
+ }
778
+
779
+ export function resolveTempoAccount(accountName: string): TempoKeyEntry | undefined {
780
+ const entries = readTempoKeystore()
781
+ if (entries.length === 0) return undefined
782
+ const suffix = accountName.slice('tempo:'.length)
783
+ if (suffix === 'default' || suffix === '') return entries[0]
784
+ const idx = Number.parseInt(suffix, 10)
785
+ if (!Number.isNaN(idx) && idx >= 0 && idx < entries.length) return entries[idx]
786
+ return undefined
787
+ }
788
+
789
+ let _tempoCliAvailable: boolean | undefined
790
+ function hasTempoCliSync(): boolean {
791
+ if (_tempoCliAvailable !== undefined) return _tempoCliAvailable
792
+ try {
793
+ child.execFileSync('which', ['tempo'], { stdio: 'ignore' })
794
+ _tempoCliAvailable = true
795
+ } catch {
796
+ _tempoCliAvailable = false
797
+ }
798
+ return _tempoCliAvailable
799
+ }
800
+
801
+ async function tempoCliSign(wwwAuth: string): Promise<string> {
802
+ return new Promise((resolve, reject) => {
803
+ child.execFile('tempo', ['mpp', 'sign', '--challenge', wwwAuth], (error, stdout, stderr) => {
804
+ if (error) {
805
+ const msg = stderr?.trim() || error.message
806
+ reject(new Error(`tempo mpp sign failed: ${msg}`))
807
+ return
808
+ }
809
+ const trimmed = stdout.trim()
810
+ if (!trimmed) {
811
+ reject(new Error('tempo mpp sign returned empty output'))
812
+ return
813
+ }
814
+ resolve(trimmed)
815
+ })
816
+ })
817
+ }
818
+
819
+ function fallbackFromTempo(): string | undefined {
820
+ const store = createDefaultStore()
821
+ const currentDefault = store.get()
822
+ if (!isTempoAccount(currentDefault)) return undefined
823
+ if (hasTempoCliSync()) return undefined
824
+ const platform = os.platform()
825
+ if (platform === 'darwin') {
826
+ try {
827
+ const stdout = child.execFileSync('security', ['dump-keychain'], { encoding: 'utf-8' })
828
+ const mppxAccounts: string[] = []
829
+ for (const block of stdout.split('keychain:')) {
830
+ const serviceMatch = block.match(/"svce"<blob>="([^"]*)"/)
831
+ const accountMatch = block.match(/"acct"<blob>="([^"]*)"/)
832
+ if (serviceMatch?.[1] === packageJson.name && accountMatch?.[1])
833
+ mppxAccounts.push(accountMatch[1])
834
+ }
835
+ if (mppxAccounts.length > 0) {
836
+ store.set(mppxAccounts[0]!)
837
+ return mppxAccounts[0]!
838
+ }
839
+ } catch {}
840
+ }
841
+ return undefined
842
+ }