@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.
- package/README.md +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- 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
|
+
})
|