@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,520 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { rm } from 'node:fs/promises'
3
+ import { cancel, confirm, intro, isCancel, note, outro, password, spinner } from '@clack/prompts'
4
+ import {
5
+ AGENT_NFT_ABI,
6
+ PromusAgentNFTReader,
7
+ NETWORK_CHAIN_ID,
8
+ type OperatorSigner,
9
+ RawPrivkeyOperatorSigner,
10
+ agentPaths,
11
+ buildTransferHashes,
12
+ explorerTokenUrl,
13
+ fetchAndDecryptKeystore,
14
+ iNFTAgentId,
15
+ reEncryptKeystoreForRecipient,
16
+ signTransferProof,
17
+ slotIndex,
18
+ waitForReceiptResilient,
19
+ } from '@promus/core'
20
+ import { type Address, type Hex, isAddress, toHex } from 'viem'
21
+ import { privateKeyToAccount } from 'viem/accounts'
22
+ import { type ParsedINFTRef, parseINFTRef } from './_inft-ref'
23
+ import { pickOperatorSigner } from './init/operator-picker'
24
+
25
+ export interface TransferOpts {
26
+ /** iNFT ref string e.g. `eip155:16661:0x9e71...:7` */
27
+ ref: string
28
+ /** Recipient operator address (the new owner). */
29
+ to: Address
30
+ /** Recipient's privkey for re-encryption sig. Falls back to PROMUS_RECIPIENT_PRIVKEY env, then interactive picker. */
31
+ recipientKey?: Hex
32
+ /** Oracle privkey for signing the transfer proof when sender does not equal teeOracle. Falls back to PROMUS_ORACLE_PRIVKEY env. */
33
+ oracleKey?: Hex
34
+ /** Re-encrypt + round-trip verify locally; do not write to chain. */
35
+ dryRun?: boolean
36
+ /** Skip the confirmation prompt. */
37
+ yes?: boolean
38
+ /** Keep profile slot unchanged (default: purge to bootstrap). */
39
+ noPurge?: boolean
40
+ }
41
+
42
+ export type ParseTransferResult = TransferOpts | { error: string }
43
+
44
+ function parsePrivkeyFlag(name: string, args: string[]): { value: Hex } | { error: string } {
45
+ const v = args.shift()
46
+ if (!v) return { error: `${name} requires a hex value` }
47
+ const norm = v.startsWith('0x') ? v : `0x${v}`
48
+ if (!/^0x[0-9a-fA-F]{64}$/.test(norm)) {
49
+ return { error: `${name} must be a 32-byte hex string` }
50
+ }
51
+ return { value: norm as Hex }
52
+ }
53
+
54
+ /**
55
+ * `promus transfer <ref> --to <addr> [--recipient-key 0x...] [--oracle-key 0x...] [--dry-run] [--yes] [--no-purge]`
56
+ *
57
+ * Positional `<ref>` is the iNFT identifier (`eip155:<chainId>:<contract>:<tokenId>`
58
+ * or shorthand). All other args are flags.
59
+ */
60
+ export function parseTransferArgs(argv: readonly string[]): ParseTransferResult {
61
+ const args = [...argv]
62
+ const out: Partial<TransferOpts> = {}
63
+ while (args.length > 0) {
64
+ const head = args.shift()!
65
+ if (head === '--to') {
66
+ const v = args.shift()
67
+ if (!v) return { error: '--to requires an address' }
68
+ if (!isAddress(v)) return { error: `--to value '${v}' is not a valid address` }
69
+ out.to = v as Address
70
+ } else if (head === '--recipient-key') {
71
+ const r = parsePrivkeyFlag('--recipient-key', args)
72
+ if ('error' in r) return r
73
+ out.recipientKey = r.value
74
+ } else if (head === '--oracle-key') {
75
+ const r = parsePrivkeyFlag('--oracle-key', args)
76
+ if ('error' in r) return r
77
+ out.oracleKey = r.value
78
+ } else if (head === '--dry-run') {
79
+ out.dryRun = true
80
+ } else if (head === '--yes' || head === '-y') {
81
+ out.yes = true
82
+ } else if (head === '--no-purge') {
83
+ out.noPurge = true
84
+ } else if (head.startsWith('-')) {
85
+ return { error: `unknown flag: ${head}` }
86
+ } else if (!out.ref) {
87
+ out.ref = head
88
+ } else {
89
+ return { error: `unexpected positional argument: ${head}` }
90
+ }
91
+ }
92
+ if (!out.ref) return { error: 'iNFT ref is required (positional)' }
93
+ if (!out.to) return { error: '--to <recipient-address> is required' }
94
+ return out as TransferOpts
95
+ }
96
+
97
+ export async function runTransfer(opts: TransferOpts): Promise<void> {
98
+ intro(opts.dryRun ? 'promus transfer (dry run)' : 'promus transfer')
99
+
100
+ let parsed: ParsedINFTRef
101
+ try {
102
+ parsed = parseINFTRef(opts.ref)
103
+ } catch (e) {
104
+ cancel((e as Error).message)
105
+ return
106
+ }
107
+ if (parsed.contract.toLowerCase() === opts.to.toLowerCase()) {
108
+ cancel('refusing transfer: --to address equals iNFT contract address')
109
+ return
110
+ }
111
+
112
+ // -------------------------------------------------------------------------
113
+ // Step 1: read current state (owner + slot hashes + oracle).
114
+ // -------------------------------------------------------------------------
115
+ const sFetch = spinner()
116
+ sFetch.start(`Fetching iNFT #${parsed.tokenId} state on ${parsed.network}`)
117
+ const reader = new PromusAgentNFTReader({
118
+ network: parsed.network,
119
+ contractAddress: parsed.contract,
120
+ })
121
+ let currentOwner: Address
122
+ let currentSlots: { dataHash: Hex }[]
123
+ try {
124
+ const [owner, slots] = await Promise.all([
125
+ reader.ownerOf(parsed.tokenId),
126
+ reader.getIntelligentData(parsed.tokenId),
127
+ ])
128
+ currentOwner = owner
129
+ currentSlots = slots
130
+ sFetch.stop(`owner=${owner} slots=${slots.length}`)
131
+ } catch (e) {
132
+ sFetch.stop(`fetch failed: ${(e as Error).message.slice(0, 200)}`)
133
+ return
134
+ }
135
+ if (opts.to.toLowerCase() === currentOwner.toLowerCase()) {
136
+ cancel('refusing transfer: --to address equals current owner')
137
+ return
138
+ }
139
+
140
+ // -------------------------------------------------------------------------
141
+ // Step 2: pick sender (operator A). Must equal current owner.
142
+ // -------------------------------------------------------------------------
143
+ const senderPicked = await pickOperatorSigner({ network: parsed.network })
144
+ if (!senderPicked) {
145
+ cancel('aborted: no sender wallet')
146
+ return
147
+ }
148
+ const sender = senderPicked.signer
149
+ const senderAddr = await sender.address()
150
+ if (senderAddr.toLowerCase() !== currentOwner.toLowerCase()) {
151
+ await sender.close?.()
152
+ cancel(
153
+ [
154
+ 'Sender wallet does not own this iNFT.',
155
+ ` current owner: ${currentOwner}`,
156
+ ` you connected: ${senderAddr}`,
157
+ ].join('\n'),
158
+ )
159
+ return
160
+ }
161
+
162
+ // -------------------------------------------------------------------------
163
+ // Step 3: prompt for agent EOA, decrypt keystore via sender wallet.
164
+ // -------------------------------------------------------------------------
165
+ const agentAddrInput = (await password({
166
+ message: 'Agent EOA address (0x...) — find via your config or the iNFT subname',
167
+ validate: v => {
168
+ if (!v) return 'Required.'
169
+ if (!/^0x[0-9a-fA-F]{40}$/.test(v)) return 'Must be a 20-byte hex address.'
170
+ return undefined
171
+ },
172
+ })) as string | symbol
173
+ if (isCancel(agentAddrInput)) {
174
+ cancel('aborted')
175
+ await sender.close?.()
176
+ return
177
+ }
178
+ const agentAddress = agentAddrInput as Address
179
+ const agentId = iNFTAgentId({ contractAddress: parsed.contract, tokenId: parsed.tokenId })
180
+ const paths = agentPaths.agent(agentId)
181
+
182
+ const sUnlock = spinner()
183
+ sUnlock.start('Decrypting agent keystore via sender wallet')
184
+ let agentPrivkey: Hex
185
+ try {
186
+ const decrypted = await fetchAndDecryptKeystore({
187
+ network: parsed.network,
188
+ contractAddress: parsed.contract,
189
+ tokenId: parsed.tokenId,
190
+ signer: sender,
191
+ agentAddress,
192
+ cachePath: paths.keystore,
193
+ })
194
+ agentPrivkey = decrypted.privkeyHex
195
+ const derived = privateKeyToAccount(agentPrivkey).address
196
+ if (derived.toLowerCase() !== agentAddress.toLowerCase()) {
197
+ sUnlock.stop('agent address mismatch')
198
+ cancel(`Decrypted privkey points to ${derived} but you said ${agentAddress}.`)
199
+ await sender.close?.()
200
+ return
201
+ }
202
+ sUnlock.stop(`unlocked agent EOA ${agentAddress}`)
203
+ } catch (e) {
204
+ sUnlock.stop(`decrypt failed: ${(e as Error).message.slice(0, 200)}`)
205
+ await sender.close?.()
206
+ return
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Step 4: resolve recipient signer.
211
+ // precedence: --recipient-key > PROMUS_RECIPIENT_PRIVKEY env > picker.
212
+ // -------------------------------------------------------------------------
213
+ const recipientKey = opts.recipientKey ?? (process.env.PROMUS_RECIPIENT_PRIVKEY as Hex | undefined)
214
+ let recipient: OperatorSigner
215
+ if (recipientKey) {
216
+ recipient = new RawPrivkeyOperatorSigner({
217
+ privkey: recipientKey,
218
+ sourceLabel: opts.recipientKey ? 'flag' : 'env:PROMUS_RECIPIENT_PRIVKEY',
219
+ })
220
+ } else {
221
+ note('Recipient signer not provided via flag/env. Pick one interactively.', 'recipient')
222
+ const picked = await pickOperatorSigner({ network: parsed.network })
223
+ if (!picked) {
224
+ cancel('aborted: no recipient signer')
225
+ await sender.close?.()
226
+ return
227
+ }
228
+ recipient = picked.signer
229
+ }
230
+ const recipientAddr = await recipient.address()
231
+ if (recipientAddr.toLowerCase() !== opts.to.toLowerCase()) {
232
+ await sender.close?.()
233
+ await recipient.close?.()
234
+ cancel(
235
+ [
236
+ 'Recipient signer address does not match --to.',
237
+ ` --to: ${opts.to}`,
238
+ ` signer addr: ${recipientAddr}`,
239
+ ].join('\n'),
240
+ )
241
+ return
242
+ }
243
+
244
+ // -------------------------------------------------------------------------
245
+ // Step 5: re-encrypt keystore with recipient's signer + upload to 0G Storage.
246
+ // -------------------------------------------------------------------------
247
+ const sReEnc = spinner()
248
+ sReEnc.start('Re-encrypting keystore for recipient + uploading to 0G Storage')
249
+ let newKeystoreHash: Hex
250
+ try {
251
+ const currentKeystoreHash = currentSlots[slotIndex('keystore')]?.dataHash
252
+ if (!currentKeystoreHash) {
253
+ throw new Error('keystore slot missing from current iNFT state')
254
+ }
255
+ newKeystoreHash = await reEncryptKeystoreForRecipient({
256
+ oldOpSigner: sender,
257
+ newOpSigner: recipient,
258
+ agentAddress,
259
+ currentRootHash: currentKeystoreHash,
260
+ network: parsed.network,
261
+ agentPrivkey,
262
+ })
263
+ sReEnc.stop(`uploaded new keystore blob: ${newKeystoreHash.slice(0, 18)}...`)
264
+ } catch (e) {
265
+ sReEnc.stop(`re-encrypt failed: ${(e as Error).message.slice(0, 200)}`)
266
+ await sender.close?.()
267
+ await recipient.close?.()
268
+ return
269
+ }
270
+
271
+ // -------------------------------------------------------------------------
272
+ // Step 6: build newHashes[6], sign oracle proof.
273
+ // -------------------------------------------------------------------------
274
+ const newHashes = buildTransferHashes({
275
+ currentHashes: currentSlots.map(s => s.dataHash),
276
+ newKeystoreHash,
277
+ purgeProfile: !opts.noPurge,
278
+ })
279
+ const proofNonce = toHex(randomBytes(32))
280
+ const chainId = NETWORK_CHAIN_ID[parsed.network]
281
+
282
+ // Determine oracle. Read on-chain teeOracle. If sender == oracle (MVP path
283
+ // where operator is also the oracle), reuse the sender signer. Otherwise
284
+ // resolve a separate oracle signer from --oracle-key flag or
285
+ // PROMUS_ORACLE_PRIVKEY env. This unblocks back-transfers and any flow where
286
+ // the iNFT owner differs from the contract's TEE oracle.
287
+ const oracleAddr = (await reader.publicClient.readContract({
288
+ address: parsed.contract,
289
+ abi: AGENT_NFT_ABI,
290
+ functionName: 'teeOracle',
291
+ })) as Address
292
+ let oracleSigner: OperatorSigner = sender
293
+ if (oracleAddr.toLowerCase() !== senderAddr.toLowerCase()) {
294
+ const oracleKey = opts.oracleKey ?? (process.env.PROMUS_ORACLE_PRIVKEY as Hex | undefined)
295
+ if (!oracleKey) {
296
+ await sender.close?.()
297
+ await recipient.close?.()
298
+ cancel(
299
+ [
300
+ 'Oracle signer required (sender does not match teeOracle).',
301
+ ` teeOracle: ${oracleAddr}`,
302
+ ` sender: ${senderAddr}`,
303
+ '',
304
+ 'Provide an oracle privkey via:',
305
+ ' --oracle-key 0x<hex>',
306
+ ' PROMUS_ORACLE_PRIVKEY=0x<hex>',
307
+ ].join('\n'),
308
+ )
309
+ return
310
+ }
311
+ const candidate: OperatorSigner = new RawPrivkeyOperatorSigner({
312
+ privkey: oracleKey,
313
+ sourceLabel: opts.oracleKey ? 'flag' : 'env:PROMUS_ORACLE_PRIVKEY',
314
+ })
315
+ const candidateAddr = await candidate.address()
316
+ if (candidateAddr.toLowerCase() !== oracleAddr.toLowerCase()) {
317
+ await candidate.close?.()
318
+ await sender.close?.()
319
+ await recipient.close?.()
320
+ cancel(
321
+ [
322
+ 'Oracle signer address does not match teeOracle.',
323
+ ` teeOracle: ${oracleAddr}`,
324
+ ` signer addr: ${candidateAddr}`,
325
+ ].join('\n'),
326
+ )
327
+ return
328
+ }
329
+ oracleSigner = candidate
330
+ }
331
+
332
+ const closeSigners = async (): Promise<void> => {
333
+ if (oracleSigner !== sender) await oracleSigner.close?.()
334
+ await recipient.close?.()
335
+ await sender.close?.()
336
+ }
337
+
338
+ const sOracle = spinner()
339
+ sOracle.start('Signing transfer proof with oracle')
340
+ let oracleSignature: Hex
341
+ try {
342
+ oracleSignature = await signTransferProof(
343
+ {
344
+ tokenId: parsed.tokenId,
345
+ from: senderAddr,
346
+ to: opts.to,
347
+ newHashes,
348
+ chainId,
349
+ proofNonce,
350
+ contractAddress: parsed.contract,
351
+ },
352
+ oracleSigner,
353
+ )
354
+ sOracle.stop('proof signed')
355
+ } catch (e) {
356
+ sOracle.stop(`oracle sign failed: ${(e as Error).message.slice(0, 200)}`)
357
+ await closeSigners()
358
+ return
359
+ }
360
+
361
+ // -------------------------------------------------------------------------
362
+ // Step 7: dry-run gate or confirm + iTransferFrom.
363
+ // -------------------------------------------------------------------------
364
+ const purgeLabel = !opts.noPurge ? 'YES (slot 3 -> bootstrap)' : 'NO'
365
+ const slotChanges = currentSlots
366
+ .map((s, i) => {
367
+ const before = s.dataHash
368
+ const after = newHashes[i]
369
+ return after === before
370
+ ? null
371
+ : ` slot ${i}: ${before.slice(0, 12)}... -> ${after?.slice(0, 12)}...`
372
+ })
373
+ .filter(Boolean)
374
+ note(
375
+ [
376
+ `iNFT: #${parsed.tokenId} at ${parsed.contract}`,
377
+ `from: ${senderAddr}`,
378
+ `to: ${opts.to}`,
379
+ `agent EOA: ${agentAddress} (unchanged)`,
380
+ `oracle: ${oracleAddr}`,
381
+ `purge profile: ${purgeLabel}`,
382
+ `nonce: ${proofNonce}`,
383
+ '',
384
+ 'slot changes:',
385
+ ...(slotChanges as string[]),
386
+ ].join('\n'),
387
+ 'transfer plan',
388
+ )
389
+
390
+ if (opts.dryRun) {
391
+ await closeSigners()
392
+ outro(
393
+ [
394
+ '',
395
+ ' dry-run complete.',
396
+ ` new keystore root: ${newKeystoreHash}`,
397
+ ' re-encryption round-trip succeeded; chain unchanged.',
398
+ ' re-run without --dry-run to commit.',
399
+ '',
400
+ ].join('\n'),
401
+ )
402
+ return
403
+ }
404
+
405
+ if (!opts.yes) {
406
+ const ok = await confirm({
407
+ message: `Commit iTransferFrom on ${parsed.network}? Sender wallet pays gas.`,
408
+ initialValue: false,
409
+ })
410
+ if (isCancel(ok) || !ok) {
411
+ cancel('aborted')
412
+ await closeSigners()
413
+ return
414
+ }
415
+ }
416
+
417
+ // Submit iTransferFrom via the operator's WalletClient directly. Avoids
418
+ // needing to extract the privkey from privkey-based signers, and keeps the
419
+ // door open for WalletConnect senders later (their walletClient signs via
420
+ // the WC relay).
421
+ const senderWallet = await sender.walletClient(parsed.network)
422
+ const senderAccount = await sender.account()
423
+ const sTx = spinner()
424
+ sTx.start('Submitting iTransferFrom on chain')
425
+ let txHash: Hex
426
+ try {
427
+ txHash = (await senderWallet.writeContract({
428
+ address: parsed.contract,
429
+ abi: AGENT_NFT_ABI,
430
+ functionName: 'iTransferFrom',
431
+ args: [
432
+ senderAddr,
433
+ opts.to,
434
+ parsed.tokenId,
435
+ [...newHashes] as Hex[],
436
+ proofNonce,
437
+ oracleSignature,
438
+ ],
439
+ chain: sender.chain(parsed.network),
440
+ account: senderAccount,
441
+ gas: BigInt(newHashes.length) * 60_000n + 200_000n,
442
+ })) as Hex
443
+ } catch (e) {
444
+ sTx.stop(`submit failed: ${(e as Error).message.slice(0, 240)}`)
445
+ await closeSigners()
446
+ return
447
+ }
448
+
449
+ // 0G mainnet block time is variable; viem's default receipt timeout is too
450
+ // short and false-fails on transactions that actually succeed. Use the core
451
+ // resilient poll helper (75 tries x 4s = 5 min budget) so we don't bail on
452
+ // a slow block.
453
+ let receiptStatus: 'success' | 'reverted' | 'unknown' = 'unknown'
454
+ try {
455
+ const receipt = await waitForReceiptResilient(reader.publicClient, txHash, {
456
+ tries: 75,
457
+ delayMs: 4000,
458
+ })
459
+ receiptStatus = receipt.status
460
+ } catch {
461
+ receiptStatus = 'unknown'
462
+ }
463
+
464
+ if (receiptStatus === 'reverted') {
465
+ sTx.stop(`tx reverted: ${txHash}`)
466
+ await closeSigners()
467
+ return
468
+ }
469
+ if (receiptStatus === 'unknown') {
470
+ sTx.stop('tx submitted; receipt poll timed out after ~5 min')
471
+ await closeSigners()
472
+ note(
473
+ [
474
+ 'Tx submitted but receipt has not surfaced yet.',
475
+ ` tx hash: ${txHash}`,
476
+ ` verify: cast tx ${txHash} --rpc-url <0g-rpc>`,
477
+ '',
478
+ 'If status=success, you can clean up local state with:',
479
+ ` rm -rf ${paths.dir}`,
480
+ ].join('\n'),
481
+ 'verify manually',
482
+ )
483
+ return
484
+ }
485
+
486
+ sTx.stop(`tx confirmed: ${txHash}`)
487
+
488
+ // -------------------------------------------------------------------------
489
+ // Step 8: cleanup + outro. Only fires on confirmed-success receipt.
490
+ // -------------------------------------------------------------------------
491
+ const senderSource = sender.source
492
+ await closeSigners()
493
+ await rm(paths.dir, { recursive: true, force: true }).catch(() => {})
494
+
495
+ if (senderSource.startsWith('raw-privkey:')) {
496
+ note(
497
+ [
498
+ `Sender ${senderAddr} was provided via raw privkey.`,
499
+ 'It may hold residual gas; sweep with cast:',
500
+ ` cast balance --rpc-url <0g-rpc> ${senderAddr}`,
501
+ ' cast send --rpc-url <0g-rpc> --private-key 0x... <main-wallet-addr> --value <wei>',
502
+ ].join('\n'),
503
+ 'sweep tip',
504
+ )
505
+ }
506
+
507
+ outro(
508
+ [
509
+ '',
510
+ ` iNFT #${parsed.tokenId} at ${parsed.contract}`,
511
+ ` new owner ${opts.to}`,
512
+ ` tx ${txHash}`,
513
+ ` explorer ${explorerTokenUrl(parsed.network, parsed.contract, parsed.tokenId)}`,
514
+ '',
515
+ 'Recipient: run `promus restore` from your environment with the recipient',
516
+ 'wallet to unlock the agent locally.',
517
+ '',
518
+ ].join('\n'),
519
+ )
520
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type {
3
+ SandboxProviderClient,
4
+ ToolboxExecBody,
5
+ ToolboxExecResponse,
6
+ } from '@promus/core'
7
+ import { parseUpgradeArgs, probeContainerBootstrapMode } from './upgrade'
8
+
9
+ // Minimal mock for SandboxProviderClient that lets us assert what `command`
10
+ // got sent to execInToolbox and stub the response. v0.21.19 added this to
11
+ // verify the bash -c wrap fix that closes Bug 2 in
12
+ // feedback-reprovision-skips-tg-and-probe-bug.
13
+ function makeMockProvider(respond: (cmd: string) => ToolboxExecResponse | Error): {
14
+ provider: SandboxProviderClient
15
+ lastCommand: { value: string | null }
16
+ } {
17
+ const lastCommand: { value: string | null } = { value: null }
18
+ const provider = {
19
+ async execInToolbox(_id: string, body: ToolboxExecBody): Promise<ToolboxExecResponse> {
20
+ lastCommand.value = body.command
21
+ const r = respond(body.command)
22
+ if (r instanceof Error) throw r
23
+ return r
24
+ },
25
+ } as unknown as SandboxProviderClient
26
+ return { provider, lastCommand }
27
+ }
28
+
29
+ describe('parseUpgradeArgs', () => {
30
+ it('empty tail → no ref, no flags', () => {
31
+ expect(parseUpgradeArgs([])).toEqual({
32
+ ref: undefined,
33
+ yes: false,
34
+ reprovision: false,
35
+ })
36
+ })
37
+ it('--yes alone → no ref', () => {
38
+ expect(parseUpgradeArgs(['--yes'])).toEqual({
39
+ ref: undefined,
40
+ yes: true,
41
+ reprovision: false,
42
+ })
43
+ })
44
+ it('positional `latest`', () => {
45
+ expect(parseUpgradeArgs(['latest'])).toEqual({
46
+ ref: 'latest',
47
+ yes: false,
48
+ reprovision: false,
49
+ })
50
+ })
51
+ it('positional tag `v0.17.8`', () => {
52
+ expect(parseUpgradeArgs(['v0.17.8'])).toEqual({
53
+ ref: 'v0.17.8',
54
+ yes: false,
55
+ reprovision: false,
56
+ })
57
+ })
58
+ it('positional + --yes', () => {
59
+ expect(parseUpgradeArgs(['latest', '--yes'])).toEqual({
60
+ ref: 'latest',
61
+ yes: true,
62
+ reprovision: false,
63
+ })
64
+ })
65
+ it('--ref takes priority over positional', () => {
66
+ expect(parseUpgradeArgs(['main', '--ref', 'v0.17.8'])).toEqual({
67
+ ref: 'v0.17.8',
68
+ yes: false,
69
+ reprovision: false,
70
+ })
71
+ })
72
+ it('--ref + --yes', () => {
73
+ expect(parseUpgradeArgs(['--ref', 'v0.17.8', '--yes'])).toEqual({
74
+ ref: 'v0.17.8',
75
+ yes: true,
76
+ reprovision: false,
77
+ })
78
+ })
79
+ it('--reprovision flag captured', () => {
80
+ expect(parseUpgradeArgs(['v0.17.8', '--reprovision', '--yes'])).toEqual({
81
+ ref: 'v0.17.8',
82
+ yes: true,
83
+ reprovision: true,
84
+ })
85
+ })
86
+ it('-y short alias works', () => {
87
+ expect(parseUpgradeArgs(['-y'])).toEqual({
88
+ ref: undefined,
89
+ yes: true,
90
+ reprovision: false,
91
+ })
92
+ })
93
+ })
94
+
95
+ describe('probeContainerBootstrapMode (bash -c wrap fix)', () => {
96
+ // Daytona's execInToolbox endpoint runs `command` argv-style — no shell
97
+ // interpretation. Bare `if [ ... ]; ...; fi` gets tokenised and `if`
98
+ // becomes argv[0]. v0.21.19 wraps the probe in `bash -c '<inner>'` so
99
+ // the inner shell handles the conditional. These tests assert both the
100
+ // command shape (regression-proof against a future un-wrap) AND the
101
+ // three response-parsing branches.
102
+
103
+ it('sends a bash -c wrapped command', async () => {
104
+ const { provider, lastCommand } = makeMockProvider(() => ({
105
+ exitCode: 0,
106
+ result: 'MODE=git\n',
107
+ }))
108
+ await probeContainerBootstrapMode(provider, 'sbx-test')
109
+ expect(lastCommand.value).not.toBeNull()
110
+ expect(lastCommand.value!.startsWith(`bash -c '`)).toBe(true)
111
+ expect(lastCommand.value!).toContain('if [ -d "$HOME/promus/.git" ]')
112
+ expect(lastCommand.value!).toContain('echo MODE=git')
113
+ expect(lastCommand.value!).toContain('echo MODE=npm')
114
+ expect(lastCommand.value!).toContain('echo MODE=none')
115
+ expect(lastCommand.value!.endsWith(`fi'`)).toBe(true)
116
+ })
117
+
118
+ it('returns "git" when stdout contains MODE=git', async () => {
119
+ const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=git\n' }))
120
+ expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBe('git')
121
+ })
122
+
123
+ it('returns "npm" when stdout contains MODE=npm', async () => {
124
+ const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=npm\n' }))
125
+ expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBe('npm')
126
+ })
127
+
128
+ it('returns null when stdout contains MODE=none', async () => {
129
+ const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=none\n' }))
130
+ expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBeNull()
131
+ })
132
+
133
+ it('returns null when exec throws (swallows network/auth errors)', async () => {
134
+ const { provider } = makeMockProvider(() => new Error('toolbox 500 internal'))
135
+ expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBeNull()
136
+ })
137
+ })