nebula-treasury 0.1.0
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 +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.ts +218 -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] nebula 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 'nebula-ai-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
|
+
| 'nebula-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
|
+
'nebula-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
|
+
'nebula-install': 'nebula 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 nebula-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.nebulaInstall)) return 'nebula-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: { 'nebula-install': `nebula ${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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the CLI package's own version. Used by `nebula --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
|
+
* `nebula-ai-cli` 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,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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.21.5 Bundle B: spawn-and-wait helper for the local gateway daemon.
|
|
3
|
+
*
|
|
4
|
+
* Two callers share this:
|
|
5
|
+
* - `nebula gateway start` (interactive Touch ID flow → spawn detached)
|
|
6
|
+
* - `nebula` chat fallback when no sock is present (auto-spawn before
|
|
7
|
+
* embedded TUI fallthrough — see Bundle C / chat.tsx)
|
|
8
|
+
*
|
|
9
|
+
* The helper does NOT perform operator-session unlock. Callers that need a
|
|
10
|
+
* fresh session must run that path before invoking this. If the daemon dies
|
|
11
|
+
* during boot because no session exists, the sock never appears and we
|
|
12
|
+
* surface the failure as `{ ready: false, reason: 'timeout' }`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, openSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { agentPaths } from 'nebula-ai-core'
|
|
20
|
+
|
|
21
|
+
export interface SpawnGatewayDaemonOpts {
|
|
22
|
+
agentId: string
|
|
23
|
+
configPath: string
|
|
24
|
+
socketPath: string
|
|
25
|
+
/** Max ms to wait for the unix sock to appear. Default 10_000. */
|
|
26
|
+
timeoutMs?: number
|
|
27
|
+
/**
|
|
28
|
+
* Where to send daemon stdout/stderr. Default 'log-file' which redirects
|
|
29
|
+
* to `~/.nebula/agents/<id>/gateway.log` (truncated on each boot) so
|
|
30
|
+
* detached daemon diagnostics survive the parent's exit. 'inherit' keeps
|
|
31
|
+
* the legacy behavior where output goes to the parent's tty (and vanishes
|
|
32
|
+
* on detach). 'ignore' drops everything.
|
|
33
|
+
*/
|
|
34
|
+
stdio?: 'inherit' | 'ignore' | 'log-file'
|
|
35
|
+
/** Override the bin resolution (tests). */
|
|
36
|
+
binPath?: string
|
|
37
|
+
/** Override env (tests). */
|
|
38
|
+
env?: NodeJS.ProcessEnv
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SpawnGatewayDaemonResult {
|
|
42
|
+
ready: boolean
|
|
43
|
+
/** Detached child PID iff spawn succeeded (regardless of readiness). */
|
|
44
|
+
pid?: number
|
|
45
|
+
/** Reason populated on failure: 'spawn-failed' | 'timeout' | 'pre-existing'. */
|
|
46
|
+
reason?: 'spawn-failed' | 'timeout' | 'pre-existing'
|
|
47
|
+
/** First-line error message when spawn failed. */
|
|
48
|
+
error?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveLocalBin(): string {
|
|
52
|
+
const pkgUrl = import.meta.resolve('nebula-ai-gateway/package.json')
|
|
53
|
+
const pkgRoot = dirname(fileURLToPath(pkgUrl))
|
|
54
|
+
return join(pkgRoot, 'bin', 'nebula-gateway-local')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function spawnGatewayDaemon(
|
|
58
|
+
opts: SpawnGatewayDaemonOpts,
|
|
59
|
+
): Promise<SpawnGatewayDaemonResult> {
|
|
60
|
+
if (existsSync(opts.socketPath)) {
|
|
61
|
+
return { ready: false, reason: 'pre-existing' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const bin = opts.binPath ?? resolveLocalBin()
|
|
65
|
+
const env: NodeJS.ProcessEnv = {
|
|
66
|
+
...(opts.env ?? process.env),
|
|
67
|
+
NEBULA_AGENT_ID: opts.agentId,
|
|
68
|
+
NEBULA_CONFIG: opts.configPath,
|
|
69
|
+
}
|
|
70
|
+
const stdioMode = opts.stdio ?? 'log-file'
|
|
71
|
+
|
|
72
|
+
// v0.21.12: when stdio is 'log-file' redirect daemon stdout+stderr to
|
|
73
|
+
// ~/.nebula/agents/<id>/gateway.log (truncate-on-restart). Pre-fix this
|
|
74
|
+
// helper used 'inherit' which sent output to the parent's tty; once the
|
|
75
|
+
// parent CLI returned, those handles vanished and operators couldn't see
|
|
76
|
+
// why the daemon misbehaved. Truncation is fine because operators rarely
|
|
77
|
+
// reboot the daemon mid-session and `nebula gateway logs -f` only follows
|
|
78
|
+
// the current invocation.
|
|
79
|
+
let stdioCfg: ['ignore', 'inherit' | 'ignore' | number, 'inherit' | 'ignore' | number]
|
|
80
|
+
if (stdioMode === 'log-file') {
|
|
81
|
+
const logPath = join(agentPaths.agent(opts.agentId).dir, 'gateway.log')
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(dirname(logPath), { recursive: true })
|
|
84
|
+
const fd = openSync(logPath, 'w') // truncate on each boot
|
|
85
|
+
stdioCfg = ['ignore', fd, fd]
|
|
86
|
+
} catch {
|
|
87
|
+
// If we can't open the log file (perm, disk), fall back to ignore so
|
|
88
|
+
// we still spawn cleanly. Operators lose diagnostics but the daemon
|
|
89
|
+
// boots.
|
|
90
|
+
stdioCfg = ['ignore', 'ignore', 'ignore']
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
stdioCfg = ['ignore', stdioMode, stdioMode]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let proc: ChildProcess
|
|
97
|
+
try {
|
|
98
|
+
proc = spawn('bun', [bin], {
|
|
99
|
+
env,
|
|
100
|
+
detached: true,
|
|
101
|
+
stdio: stdioCfg,
|
|
102
|
+
})
|
|
103
|
+
proc.unref()
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
ready: false,
|
|
107
|
+
reason: 'spawn-failed',
|
|
108
|
+
error: (err as Error).message?.slice(0, 200),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
113
|
+
const start = Date.now()
|
|
114
|
+
while (Date.now() - start < timeoutMs) {
|
|
115
|
+
if (existsSync(opts.socketPath)) {
|
|
116
|
+
return { ready: true, pid: proc.pid }
|
|
117
|
+
}
|
|
118
|
+
await new Promise(r => setTimeout(r, 200))
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ready: false,
|
|
122
|
+
pid: proc.pid,
|
|
123
|
+
reason: 'timeout',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.23.2: detect and auto-heal version drift between the on-disk CLI binary
|
|
3
|
+
* and a running gateway daemon.
|
|
4
|
+
*
|
|
5
|
+
* Scenario this fixes:
|
|
6
|
+
* 1. Operator runs `bun add -g nebula-ai-cli@<new>` — global binary
|
|
7
|
+
* swaps on disk.
|
|
8
|
+
* 2. The previously-running gateway daemon was spawned from the OLD binary
|
|
9
|
+
* and pinned its node_modules at boot. `/healthz` reports the old version
|
|
10
|
+
* forever.
|
|
11
|
+
* 3. Operator runs `nebula` (chat) or `nebula gateway start`. Without this
|
|
12
|
+
* helper, chat.tsx re-attaches to the stale daemon — operator sees old
|
|
13
|
+
* features for the entire daemon lifetime.
|
|
14
|
+
*
|
|
15
|
+
* With this helper: any caller that sees a pre-existing socket calls
|
|
16
|
+
* `ensureGatewayVersionMatchesCli` first, which fetches /healthz, compares
|
|
17
|
+
* versions, and if drift is detected: kills the old daemon, removes the
|
|
18
|
+
* stale socket, and returns 'restarted' so the caller respawns fresh.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
export interface VersionCheckOpts {
|
|
25
|
+
/** Path to the gateway's unix socket. */
|
|
26
|
+
socketPath: string
|
|
27
|
+
/** Path to the lockfile holding daemon pid (for SIGTERM). */
|
|
28
|
+
lockFile?: string
|
|
29
|
+
/** Override the on-disk CLI version (tests). */
|
|
30
|
+
cliVersion?: string
|
|
31
|
+
/** Override the fetch implementation (tests). */
|
|
32
|
+
fetchImpl?: typeof fetch
|
|
33
|
+
/** Max ms to wait after SIGTERM for the socket to disappear. Default 4000. */
|
|
34
|
+
killTimeoutMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VersionCheckResult {
|
|
38
|
+
/** What we observed and did. */
|
|
39
|
+
action: 'ok' | 'restarted' | 'unreachable' | 'no-cli-version'
|
|
40
|
+
cliVersion?: string
|
|
41
|
+
daemonVersion?: string
|
|
42
|
+
/** Human-readable note for the operator. */
|
|
43
|
+
note?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the version baked into the nebula-ai-gateway package on disk. */
|
|
47
|
+
export function readLocalGatewayVersion(): string | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const pkgUrl = import.meta.resolve('nebula-ai-gateway/package.json')
|
|
50
|
+
const pkgPath = fileURLToPath(pkgUrl)
|
|
51
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }
|
|
52
|
+
return pkg.version
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fetch /healthz over the unix socket. Returns the daemon's reported version. */
|
|
59
|
+
export async function fetchDaemonVersion(
|
|
60
|
+
socketPath: string,
|
|
61
|
+
fetchImpl?: typeof fetch,
|
|
62
|
+
): Promise<string | undefined> {
|
|
63
|
+
const f = fetchImpl ?? globalThis.fetch
|
|
64
|
+
try {
|
|
65
|
+
const r = await f('http://localhost/healthz', { unix: socketPath } as RequestInit & {
|
|
66
|
+
unix: string
|
|
67
|
+
})
|
|
68
|
+
if (!r.ok) return undefined
|
|
69
|
+
const body = (await r.json()) as { version?: string }
|
|
70
|
+
return body.version
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compare running-daemon version against on-disk CLI version. If they drift,
|
|
78
|
+
* SIGTERM the pid in the lockfile, wait up to killTimeoutMs for the socket
|
|
79
|
+
* to disappear, and return `action='restarted'` to signal the caller to
|
|
80
|
+
* spawn a fresh daemon.
|
|
81
|
+
*
|
|
82
|
+
* If versions match → `action='ok'`.
|
|
83
|
+
* If /healthz unreachable (zombie socket) → `action='unreachable'` after
|
|
84
|
+
* cleaning the stale socket file so the caller can spawn fresh.
|
|
85
|
+
* If on-disk CLI version cannot be resolved → `action='no-cli-version'`
|
|
86
|
+
* (skip check defensively).
|
|
87
|
+
*/
|
|
88
|
+
export async function ensureGatewayVersionMatchesCli(
|
|
89
|
+
opts: VersionCheckOpts,
|
|
90
|
+
): Promise<VersionCheckResult> {
|
|
91
|
+
if (!existsSync(opts.socketPath)) {
|
|
92
|
+
return { action: 'ok', note: 'no socket; nothing to check' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cliVersion = opts.cliVersion ?? readLocalGatewayVersion()
|
|
96
|
+
if (!cliVersion) {
|
|
97
|
+
return {
|
|
98
|
+
action: 'no-cli-version',
|
|
99
|
+
note: 'could not resolve on-disk CLI version; skipping drift check',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const daemonVersion = await fetchDaemonVersion(opts.socketPath, opts.fetchImpl)
|
|
104
|
+
if (!daemonVersion) {
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(opts.socketPath)
|
|
107
|
+
} catch {}
|
|
108
|
+
return {
|
|
109
|
+
action: 'unreachable',
|
|
110
|
+
cliVersion,
|
|
111
|
+
note: 'daemon socket present but /healthz unreachable; removed stale socket',
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (daemonVersion === cliVersion) {
|
|
116
|
+
return { action: 'ok', cliVersion, daemonVersion }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Drift detected. Kill the daemon via pid in lockfile.
|
|
120
|
+
let killedPid: number | undefined
|
|
121
|
+
if (opts.lockFile && existsSync(opts.lockFile)) {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(readFileSync(opts.lockFile, 'utf8')) as { pid?: number }
|
|
124
|
+
if (typeof parsed.pid === 'number') {
|
|
125
|
+
try {
|
|
126
|
+
process.kill(parsed.pid, 'SIGTERM')
|
|
127
|
+
killedPid = parsed.pid
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Wait for the socket to disappear (daemon exits, cleans up).
|
|
134
|
+
const killTimeoutMs = opts.killTimeoutMs ?? 4000
|
|
135
|
+
const deadline = Date.now() + killTimeoutMs
|
|
136
|
+
while (Date.now() < deadline && existsSync(opts.socketPath)) {
|
|
137
|
+
await new Promise(r => setTimeout(r, 100))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If socket still here, force-remove it. The lockfile cleanup happens when
|
|
141
|
+
// the parent invokes spawnGatewayDaemon (which clears stale locks at boot).
|
|
142
|
+
if (existsSync(opts.socketPath)) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(opts.socketPath)
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
action: 'restarted',
|
|
150
|
+
cliVersion,
|
|
151
|
+
daemonVersion,
|
|
152
|
+
note: `version drift: daemon=${daemonVersion} vs cli=${cliVersion}; killed pid=${killedPid ?? '?'}, socket cleaned`,
|
|
153
|
+
}
|
|
154
|
+
}
|