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.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. 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
+ }