@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,378 @@
1
+ /**
2
+ * Multi-line ANSI progress box for sandbox bootstrap. Replaces the single-line
3
+ * clack spinner across the launch + poll + /healthz window:
4
+ *
5
+ * ╭─ bootstrap progress ────────────────────────╮
6
+ * │ [00:00] launchScript uploaded to Daytona │
7
+ * │ [00:12] apt update ✓ │
8
+ * │ [00:38] system deps installed ✓ │
9
+ * │ [01:04] bun runtime installed ✓ │
10
+ * │ [01:22] promus 0.24.7 installed ✓ │
11
+ * │ [01:45] browser deps installed ✓ │
12
+ * │ [02:08] harness daemon spawned ✓ │
13
+ * │ [02:11] /healthz Ready ✓ │
14
+ * ╰─────────────────────────────────────────────╯
15
+ *
16
+ * Rendering uses `\x1b[NA` (cursor up) + `\x1b[0J` (clear to end) to redraw
17
+ * the same N+2 lines in place. Falls back to per-transition lines (no ANSI)
18
+ * when stdout is not a TTY (CI, piped output).
19
+ */
20
+
21
+ import { BOOTSTRAP_STAGE_MARKERS } from '@promus/gateway'
22
+
23
+ const TIME_SLOT_WIDTH = 7
24
+ const LABEL_WIDTH = 32
25
+ const CONTENT_WIDTH = 45
26
+ const FRAME_WIDTH = CONTENT_WIDTH + 2
27
+
28
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] as const
29
+
30
+ export type BootstrapStageId =
31
+ | 'launch-upload'
32
+ | 'apt-update'
33
+ | 'system-deps'
34
+ | 'bun-install'
35
+ | 'promus-install'
36
+ | 'browser-deps'
37
+ | 'harness-spawn'
38
+ | 'healthz-ready'
39
+
40
+ export type BootstrapStageStatus = 'pending' | 'running' | 'done' | 'failed'
41
+
42
+ const STAGE_ORDER: readonly BootstrapStageId[] = [
43
+ 'launch-upload',
44
+ 'apt-update',
45
+ 'system-deps',
46
+ 'bun-install',
47
+ 'promus-install',
48
+ 'browser-deps',
49
+ 'harness-spawn',
50
+ 'healthz-ready',
51
+ ] as const
52
+
53
+ const DEFAULT_LABELS: Record<BootstrapStageId, string> = {
54
+ 'launch-upload': 'launchScript uploaded to Daytona',
55
+ 'apt-update': 'apt update',
56
+ 'system-deps': 'system deps installed',
57
+ 'bun-install': 'bun runtime installed',
58
+ 'promus-install': 'promus installed',
59
+ 'browser-deps': 'browser deps installed',
60
+ 'harness-spawn': 'harness daemon spawned',
61
+ 'healthz-ready': '/healthz Ready',
62
+ }
63
+
64
+ interface StageState {
65
+ id: BootstrapStageId
66
+ label: string
67
+ status: BootstrapStageStatus
68
+ /** Seconds since box start when status transitioned to running. */
69
+ startedSec?: number
70
+ /** Seconds since box start when status transitioned to done/failed. */
71
+ endedSec?: number
72
+ }
73
+
74
+ export interface BootstrapProgressBoxOpts {
75
+ /** Box title. Defaults to "bootstrap progress". */
76
+ title?: string
77
+ /** Per-stage label override. Useful for injecting the version into promus-install. */
78
+ labels?: Partial<Record<BootstrapStageId, string>>
79
+ /** Stream to write to. Defaults to process.stdout. */
80
+ out?: NodeJS.WritableStream & { isTTY?: boolean }
81
+ }
82
+
83
+ /**
84
+ * Map a raw STAGE marker (emitted by the gateway bootstrap script as
85
+ * `STAGE: <body>` and extracted by sandbox-provision's poll loop) to a stage
86
+ * id. Returns null for unknown markers; callers should treat unknown markers
87
+ * as informational, not as state transitions. Marker prefixes come from
88
+ * `BOOTSTRAP_STAGE_MARKERS` in the gateway package so renames stay in lockstep.
89
+ */
90
+ export function mapBootstrapMarkerToStage(marker: string): BootstrapStageId | null {
91
+ const m = marker.trim()
92
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.aptUpdate)) return 'apt-update'
93
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.systemDeps)) return 'system-deps'
94
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.bunInstall)) return 'bun-install'
95
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.promusInstall)) return 'promus-install'
96
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.browserDeps)) return 'browser-deps'
97
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.harnessSpawn)) return 'harness-spawn'
98
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.harnessReady)) return 'harness-spawn'
99
+ return null
100
+ }
101
+
102
+ export class BootstrapProgressBox {
103
+ private readonly title: string
104
+ private readonly out: NodeJS.WritableStream & { isTTY?: boolean }
105
+ private readonly useAnsi: boolean
106
+ private readonly stages: StageState[]
107
+ private startMs = 0
108
+ private tickIdx = 0
109
+ /** Number of lines we wrote the LAST time render() ran (so we know how
110
+ * many to clear before the next render). Zero before the first render. */
111
+ private renderedLines = 0
112
+ /** Cleared on render — tracks last-printed status per stage in non-TTY mode. */
113
+ private readonly nonTtyLastPrinted = new Map<BootstrapStageId, BootstrapStageStatus>()
114
+
115
+ constructor(opts: BootstrapProgressBoxOpts = {}) {
116
+ this.title = opts.title ?? 'bootstrap progress'
117
+ this.out = opts.out ?? process.stdout
118
+ this.useAnsi = this.out.isTTY === true
119
+ this.stages = STAGE_ORDER.map(id => ({
120
+ id,
121
+ label: opts.labels?.[id] ?? DEFAULT_LABELS[id],
122
+ status: 'pending' as BootstrapStageStatus,
123
+ }))
124
+ }
125
+
126
+ start(): void {
127
+ this.startMs = Date.now()
128
+ if (!this.useAnsi) return
129
+ this.render()
130
+ }
131
+
132
+ /**
133
+ * Update one stage. When `status === 'running'`, every previously-running
134
+ * stage that hasn't transitioned to done/failed is auto-completed (the
135
+ * bash script is sequential, so a new stage starting implies the prior
136
+ * ones finished). Stages before the activated one that are still pending
137
+ * are also auto-completed — this handles conditional stages (e.g.
138
+ * bun-install gets skipped when bun is already in PATH).
139
+ */
140
+ markStage(id: BootstrapStageId, status: BootstrapStageStatus): void {
141
+ const sec = this.elapsedSec()
142
+ const idx = this.stages.findIndex(s => s.id === id)
143
+ if (idx < 0) return
144
+ if (status === 'running') {
145
+ for (let i = 0; i < idx; i++) {
146
+ const s = this.stages[i]!
147
+ if (s.status !== 'done' && s.status !== 'failed') {
148
+ s.status = 'done'
149
+ s.endedSec = sec
150
+ }
151
+ }
152
+ const target = this.stages[idx]!
153
+ if (target.status === 'pending') target.startedSec = sec
154
+ target.status = 'running'
155
+ } else {
156
+ const target = this.stages[idx]!
157
+ target.status = status
158
+ target.endedSec = sec
159
+ if (target.startedSec === undefined) target.startedSec = sec
160
+ }
161
+ this.tickIdx += 1
162
+ this.render()
163
+ }
164
+
165
+ /**
166
+ * Bump the spinner glyph + elapsed counter. Call every 1-5s while a
167
+ * running stage is in-flight to keep the visual alive even when no STAGE
168
+ * marker has arrived. No-op when the box hasn't started yet or when no
169
+ * stage is currently running.
170
+ */
171
+ tick(): void {
172
+ if (this.renderedLines === 0 && this.useAnsi) {
173
+ this.render()
174
+ return
175
+ }
176
+ this.tickIdx += 1
177
+ this.render()
178
+ }
179
+
180
+ /**
181
+ * Finalize the box. Marks any still-running stages as done (assumed
182
+ * complete; the upstream success signal is what triggered stop()), draws
183
+ * one final frame, and emits the trailing newline so subsequent CLI
184
+ * output (e.g. final spinner success line) starts cleanly below.
185
+ */
186
+ stop(): void {
187
+ const sec = this.elapsedSec()
188
+ for (const s of this.stages) {
189
+ if (s.status === 'running') {
190
+ s.status = 'done'
191
+ s.endedSec = sec
192
+ }
193
+ }
194
+ this.render()
195
+ if (this.useAnsi) this.out.write('\n')
196
+ }
197
+
198
+ /**
199
+ * Mark the box as aborted. Any running stage becomes failed; pending
200
+ * stages stay pending so the operator sees where bootstrap got stuck.
201
+ */
202
+ fail(): void {
203
+ const sec = this.elapsedSec()
204
+ for (const s of this.stages) {
205
+ if (s.status === 'running') {
206
+ s.status = 'failed'
207
+ s.endedSec = sec
208
+ }
209
+ }
210
+ this.render()
211
+ if (this.useAnsi) this.out.write('\n')
212
+ }
213
+
214
+ private elapsedSec(): number {
215
+ return Math.max(0, Math.round((Date.now() - this.startMs) / 1000))
216
+ }
217
+
218
+ private render(): void {
219
+ if (!this.useAnsi) {
220
+ this.renderNonTty()
221
+ return
222
+ }
223
+ const lines = this.buildLines()
224
+ if (this.renderedLines > 0) {
225
+ this.out.write(`\x1b[${this.renderedLines}A\x1b[0J`)
226
+ }
227
+ for (const line of lines) this.out.write(`${line}\n`)
228
+ this.renderedLines = lines.length
229
+ }
230
+
231
+ /**
232
+ * Non-TTY fallback: print one line per state change instead of a redrawn
233
+ * box. Tracks last-printed status per stage so re-renders don't spam.
234
+ */
235
+ private renderNonTty(): void {
236
+ for (const s of this.stages) {
237
+ const prev = this.nonTtyLastPrinted.get(s.id)
238
+ if (prev === s.status) continue
239
+ if (s.status === 'pending') continue
240
+ const tag =
241
+ s.status === 'done'
242
+ ? '[ok]'
243
+ : s.status === 'failed'
244
+ ? '[fail]'
245
+ : s.status === 'running'
246
+ ? '[..]'
247
+ : ''
248
+ const time =
249
+ s.status === 'running'
250
+ ? formatTime(s.startedSec ?? 0)
251
+ : formatTime(s.endedSec ?? this.elapsedSec())
252
+ this.out.write(`${tag} [${time}] ${s.label}\n`)
253
+ this.nonTtyLastPrinted.set(s.id, s.status)
254
+ }
255
+ }
256
+
257
+ private buildLines(): string[] {
258
+ const header = `╭─ ${this.title} ${'─'.repeat(FRAME_WIDTH - this.title.length - 5)}╮`
259
+ const footer = `╰${'─'.repeat(FRAME_WIDTH - 2)}╯`
260
+ const rows = this.stages.map(s => this.formatRow(s))
261
+ return [header, ...rows, footer]
262
+ }
263
+
264
+ private formatRow(s: StageState): string {
265
+ const timeText = pickTimeText(s)
266
+ const timeCol =
267
+ timeText === null ? ' '.repeat(TIME_SLOT_WIDTH) : `[${timeText}]`.padEnd(TIME_SLOT_WIDTH)
268
+ const labelText = truncate(s.label, LABEL_WIDTH).padEnd(LABEL_WIDTH)
269
+ const glyph = pickGlyph(s, this.tickIdx)
270
+ return `│ ${timeCol} ${labelText} ${glyph} │`
271
+ }
272
+ }
273
+
274
+ function pickTimeText(s: StageState): string | null {
275
+ if (s.status === 'pending') return null
276
+ const sec = s.status === 'running' ? (s.startedSec ?? 0) : (s.endedSec ?? s.startedSec ?? 0)
277
+ return formatTime(sec)
278
+ }
279
+
280
+ function formatTime(sec: number): string {
281
+ const m = Math.floor(sec / 60)
282
+ const s = sec % 60
283
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
284
+ }
285
+
286
+ function pickGlyph(s: StageState, tickIdx: number): string {
287
+ if (s.status === 'done') return '✓'
288
+ if (s.status === 'failed') return '✗'
289
+ if (s.status === 'running') return SPINNER_FRAMES[tickIdx % SPINNER_FRAMES.length]!
290
+ return ' '
291
+ }
292
+
293
+ function truncate(s: string, max: number): string {
294
+ if (s.length <= max) return s
295
+ return `${s.slice(0, max - 1)}…`
296
+ }
297
+
298
+ export const __testing = {
299
+ STAGE_ORDER,
300
+ DEFAULT_LABELS,
301
+ SPINNER_FRAMES,
302
+ formatTime,
303
+ truncate,
304
+ }
305
+
306
+ /**
307
+ * Lifecycle owner for the bootstrap progress UX. Wraps the clack spinner
308
+ * (which renders the pre-bootstrap phase: deposit + createSandbox) and a
309
+ * lazily-created `BootstrapProgressBox` (which renders the actual bootstrap
310
+ * stages). Encapsulates the takeover handoff so `init.ts` and `upgrade.ts`
311
+ * don't repeat the same `let box / spinnerStopped` dance.
312
+ */
313
+ export interface ClackSpinnerLike {
314
+ message: (msg: string) => void
315
+ stop: (msg?: string, code?: number) => void
316
+ }
317
+
318
+ export interface BootstrapProgressControllerOpts {
319
+ spinner: ClackSpinnerLike
320
+ cliVersion: string
321
+ /** Text shown when the spinner stops + the box takes over (e.g. "sandbox started, running bootstrap"). */
322
+ startedMsg: string
323
+ }
324
+
325
+ export class BootstrapProgressController {
326
+ private box: BootstrapProgressBox | null = null
327
+ private spinnerStopped = false
328
+ private readonly spinner: ClackSpinnerLike
329
+ private readonly cliVersion: string
330
+ private readonly startedMsg: string
331
+
332
+ constructor(opts: BootstrapProgressControllerOpts) {
333
+ this.spinner = opts.spinner
334
+ this.cliVersion = opts.cliVersion
335
+ this.startedMsg = opts.startedMsg
336
+ }
337
+
338
+ onProgress = (msg: string): void => {
339
+ if (this.box) return
340
+ this.spinner.message(msg)
341
+ }
342
+
343
+ onStageEvent = (stage: BootstrapStageId, status: BootstrapStageStatus): void => {
344
+ if (!this.box) {
345
+ this.spinner.stop(this.startedMsg)
346
+ this.spinnerStopped = true
347
+ this.box = new BootstrapProgressBox({
348
+ labels: { 'promus-install': `promus ${this.cliVersion} installed` },
349
+ })
350
+ this.box.start()
351
+ }
352
+ this.box.markStage(stage, status)
353
+ }
354
+
355
+ onTick = (): void => {
356
+ this.box?.tick()
357
+ }
358
+
359
+ /** Box closes itself; success line printed via the caller's `emit` (typically `log.step`). */
360
+ finalize(successLine: string, emit: (msg: string) => void): void {
361
+ if (this.box) {
362
+ this.box.stop()
363
+ emit(successLine)
364
+ } else {
365
+ this.spinner.stop(successLine)
366
+ }
367
+ }
368
+
369
+ /** Box marks running stage as failed; error line printed via caller's `emit` (typically `log.error`). */
370
+ fail(errLine: string, emit: (msg: string) => void): void {
371
+ if (this.box) {
372
+ this.box.fail()
373
+ emit(errLine)
374
+ } else if (!this.spinnerStopped) {
375
+ this.spinner.stop(errLine)
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Local persistence for brain provider secrets (API keys), encrypted via the
3
+ * operator's sign-derived AEAD key (scope `OPERATOR_BLOB_SCOPES.BRAIN`).
4
+ *
5
+ * On-disk file: `~/.promus/agents/<id>/brain-secrets.encrypted`
6
+ *
7
+ * {
8
+ * version: 2,
9
+ * scope: 'promus-brain-v1',
10
+ * blob: <base64(iv|tag|ciphertext)>,
11
+ * }
12
+ *
13
+ * Plaintext shape inside the blob:
14
+ *
15
+ * {
16
+ * provider: 'anthropic' | 'openai' | 'google',
17
+ * apiKey: string,
18
+ * model?: string,
19
+ * ipfsApiUrl?: string,
20
+ * ipfsGateway?: string,
21
+ * ipfsApiToken?: string,
22
+ * }
23
+ */
24
+ import { existsSync } from 'node:fs'
25
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
26
+ import { dirname, join } from 'node:path'
27
+ import {
28
+ OPERATOR_BLOB_SCOPES,
29
+ type OperatorEncryptedBlob,
30
+ type OperatorSigner,
31
+ agentPaths,
32
+ decodeOperatorBlobBytes,
33
+ decryptOperatorBlob,
34
+ encodeOperatorBlobBytes,
35
+ encryptOperatorBlob,
36
+ } from '@promus/core'
37
+ import type { Address } from 'viem'
38
+
39
+ export interface BrainSecretsPlaintext {
40
+ provider: 'anthropic' | 'openai' | 'google'
41
+ apiKey: string
42
+ model?: string
43
+ ipfsApiUrl?: string
44
+ ipfsGateway?: string
45
+ ipfsApiToken?: string
46
+ }
47
+
48
+ export function brainSecretsPath(agentId: string): string {
49
+ return join(agentPaths.agent(agentId).dir, 'brain-secrets.encrypted')
50
+ }
51
+
52
+ export function brainSecretsExist(agentId: string): boolean {
53
+ return existsSync(brainSecretsPath(agentId))
54
+ }
55
+
56
+ export async function loadBrainSecrets(opts: {
57
+ signer: OperatorSigner
58
+ agentAddress: Address
59
+ agentId: string
60
+ }): Promise<BrainSecretsPlaintext | null> {
61
+ const path = brainSecretsPath(opts.agentId)
62
+ if (!existsSync(path)) return null
63
+ const fileBytes = await readFile(path)
64
+ const blob: OperatorEncryptedBlob = decodeOperatorBlobBytes(new Uint8Array(fileBytes))
65
+ const ptBytes = await decryptOperatorBlob({
66
+ signer: opts.signer,
67
+ scope: OPERATOR_BLOB_SCOPES.BRAIN,
68
+ agentAddress: opts.agentAddress,
69
+ blob,
70
+ })
71
+ const parsed = JSON.parse(new TextDecoder().decode(ptBytes)) as BrainSecretsPlaintext
72
+ if (typeof parsed.apiKey !== 'string' || typeof parsed.provider !== 'string') {
73
+ throw new Error('brain-secrets: malformed plaintext (missing apiKey or provider)')
74
+ }
75
+ return parsed
76
+ }
77
+
78
+ export async function saveBrainSecrets(opts: {
79
+ signer: OperatorSigner
80
+ agentAddress: Address
81
+ agentId: string
82
+ plaintext: BrainSecretsPlaintext
83
+ precomputedKey?: Buffer
84
+ }): Promise<void> {
85
+ const path = brainSecretsPath(opts.agentId)
86
+ await mkdir(dirname(path), { recursive: true })
87
+ const ptBytes = new TextEncoder().encode(JSON.stringify(opts.plaintext))
88
+ const blob = await encryptOperatorBlob({
89
+ signer: opts.signer,
90
+ scope: OPERATOR_BLOB_SCOPES.BRAIN,
91
+ agentAddress: opts.agentAddress,
92
+ plaintext: ptBytes,
93
+ precomputedKey: opts.precomputedKey,
94
+ })
95
+ await writeFile(path, encodeOperatorBlobBytes(blob))
96
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolve the CLI package's own version. Used by `promus --version` and to pin
3
+ * the gateway version installed in sandbox containers (mode=npm) so the
4
+ * gateway matches the CLI.
5
+ *
6
+ * Reads package.json via a path relative to this module so it works in every
7
+ * install layout: monorepo workspace (where bare-specifier resolution of
8
+ * `promus` doesn't include /package.json without an exports entry),
9
+ * `bun add -g` global install, and Bun's per-project content store.
10
+ */
11
+ import { readFile } from 'node:fs/promises'
12
+ import { resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ export async function resolveCliVersion(): Promise<string> {
16
+ const here = fileURLToPath(import.meta.url)
17
+ const pkgPath = resolve(here, '../../../package.json')
18
+ try {
19
+ const raw = await readFile(pkgPath, 'utf-8')
20
+ const pkg = JSON.parse(raw) as { version?: unknown }
21
+ if (typeof pkg.version !== 'string') {
22
+ throw new Error('package.json missing version field')
23
+ }
24
+ return pkg.version
25
+ } catch (e) {
26
+ throw new Error(`cannot read CLI version from ${pkgPath}: ${(e as Error).message}`)
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { shortAddr } from './format'
3
+
4
+ describe('shortAddr', () => {
5
+ it('returns ? for missing input', () => {
6
+ expect(shortAddr(undefined)).toBe('?')
7
+ expect(shortAddr('')).toBe('?')
8
+ })
9
+ it('passes through short / non-0x values unchanged', () => {
10
+ expect(shortAddr('alice.0g')).toBe('alice.0g')
11
+ expect(shortAddr('0xabc')).toBe('0xabc')
12
+ })
13
+ it('truncates a 0x EVM address to first 6 + last 4', () => {
14
+ expect(shortAddr('0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec')).toBe('0xC635…87Ec')
15
+ })
16
+ })
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Truncate an EVM 0x-address to first 6 + last 4 (e.g. 0x1234…abcd) for
3
+ * compact UI rendering. Returns the input unchanged for short or non-0x
4
+ * values (e.g. an `.0g` name, `'?'`, or empty), so callers can pass any
5
+ * identifier without checking type first.
6
+ */
7
+ export function shortAddr(addr?: string | null): string {
8
+ if (!addr) return '?'
9
+ if (!addr.startsWith('0x') || addr.length <= 12) return addr
10
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`
11
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Bundle B unit tests: spawnGatewayDaemon path. Uses a tiny shell-stub bin
3
+ * that "binds" the socket by simply touching the path then sleeping. We
4
+ * avoid spinning up the real `bun packages/gateway/bin/@promus/gateway-local`
5
+ * because that pulls in keystore + viem + 0G SDKs.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
9
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { spawnGatewayDaemon } from './gateway-spawn'
13
+
14
+ let workDir: string
15
+
16
+ beforeEach(() => {
17
+ workDir = mkdtempSync(join(tmpdir(), 'promus-spawn-test-'))
18
+ })
19
+
20
+ afterEach(() => {
21
+ rmSync(workDir, { recursive: true, force: true })
22
+ })
23
+
24
+ describe('spawnGatewayDaemon', () => {
25
+ it('reports pre-existing when sock already present', async () => {
26
+ const sock = join(workDir, 'gateway.sock')
27
+ writeFileSync(sock, '')
28
+ const r = await spawnGatewayDaemon({
29
+ agentId: 'a1',
30
+ configPath: 'cfg',
31
+ socketPath: sock,
32
+ binPath: join(workDir, 'unused-bin'),
33
+ timeoutMs: 100,
34
+ })
35
+ expect(r.ready).toBe(false)
36
+ expect(r.reason).toBe('pre-existing')
37
+ })
38
+
39
+ it('reports timeout when bin does not bind the sock', async () => {
40
+ const sock = join(workDir, 'gateway.sock')
41
+ const stub = join(workDir, 'noop.ts')
42
+ writeFileSync(stub, 'setTimeout(() => process.exit(0), 50)\n')
43
+ const r = await spawnGatewayDaemon({
44
+ agentId: 'a3',
45
+ configPath: 'cfg',
46
+ socketPath: sock,
47
+ binPath: stub,
48
+ timeoutMs: 600,
49
+ stdio: 'ignore',
50
+ })
51
+ expect(r.ready).toBe(false)
52
+ expect(r.reason).toBe('timeout')
53
+ expect(existsSync(sock)).toBe(false)
54
+ })
55
+
56
+ it('returns ready=true when bin binds the sock + passes env', async () => {
57
+ const sock = join(workDir, 'gateway.sock')
58
+ const envOut = join(workDir, 'env.out')
59
+ const stub = join(workDir, 'env-stub.ts')
60
+ writeFileSync(
61
+ stub,
62
+ `import { writeFileSync } from 'node:fs'
63
+ writeFileSync(${JSON.stringify(envOut)}, JSON.stringify({
64
+ agent: process.env.PROMUS_AGENT_ID,
65
+ config: process.env.PROMUS_CONFIG,
66
+ }))
67
+ writeFileSync(${JSON.stringify(sock)}, '')
68
+ setTimeout(() => process.exit(0), 200)
69
+ `,
70
+ )
71
+ const r = await spawnGatewayDaemon({
72
+ agentId: 'agent-EXPECTED',
73
+ configPath: '/path/to/cfg.ts',
74
+ socketPath: sock,
75
+ binPath: stub,
76
+ timeoutMs: 8_000,
77
+ stdio: 'ignore',
78
+ })
79
+ expect(r.ready).toBe(true)
80
+ expect(r.pid).toBeNumber()
81
+ const { readFileSync } = await import('node:fs')
82
+ const captured = JSON.parse(readFileSync(envOut, 'utf8'))
83
+ expect(captured.agent).toBe('agent-EXPECTED')
84
+ expect(captured.config).toBe('/path/to/cfg.ts')
85
+ })
86
+ })