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,79 @@
1
+ // GitHub Releases API helpers. Unauthenticated (60 req/hr per IP) which is
2
+ // plenty for the upgrade hot path: one resolveLatestRelease + zero-or-one
3
+ // checkTagExists per invocation. Pin `--ref vX.Y.Z` to skip the API entirely.
4
+ export interface GitHubRelease {
5
+ tagName: string
6
+ publishedAt: string
7
+ htmlUrl: string
8
+ }
9
+
10
+ export interface GitHubFetchOpts {
11
+ /** Override fetch (mainly for tests). Defaults to global `fetch`. */
12
+ fetchImpl?: typeof fetch
13
+ /** Per-call timeout. Defaults to 10s. */
14
+ timeoutMs?: number
15
+ }
16
+
17
+ /**
18
+ * Parse `https://github.com/owner/repo.git`, `https://github.com/owner/repo`,
19
+ * or `git@github.com:owner/repo.git` into `{owner, repo}`. Throws on shapes
20
+ * the regex doesn't recognize.
21
+ */
22
+ export function parseGitHubRepoUrl(url: string): { owner: string; repo: string } {
23
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
24
+ if (!match || !match[1] || !match[2]) throw new Error(`cannot parse GitHub repo URL: ${url}`)
25
+ return { owner: match[1], repo: match[2] }
26
+ }
27
+
28
+ /**
29
+ * Resolve the most recent published GitHub release for a repo. Skips drafts
30
+ * and pre-releases (that's what GitHub's `/releases/latest` endpoint returns
31
+ * by default). Throws on 404 (no published release) or non-200.
32
+ */
33
+ export async function resolveLatestRelease(
34
+ repoUrl: string,
35
+ opts: GitHubFetchOpts = {},
36
+ ): Promise<GitHubRelease> {
37
+ const { owner, repo } = parseGitHubRepoUrl(repoUrl)
38
+ const fetchImpl = opts.fetchImpl ?? fetch
39
+ const timeoutMs = opts.timeoutMs ?? 10_000
40
+ const r = await fetchImpl(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
41
+ headers: { Accept: 'application/vnd.github+json' },
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ })
44
+ if (r.status === 404) {
45
+ throw new Error(`no published releases found for ${owner}/${repo}`)
46
+ }
47
+ if (!r.ok) {
48
+ throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/releases/latest`)
49
+ }
50
+ const data = (await r.json()) as { tag_name: string; published_at: string; html_url: string }
51
+ return { tagName: data.tag_name, publishedAt: data.published_at, htmlUrl: data.html_url }
52
+ }
53
+
54
+ /**
55
+ * Probe whether a tag exists on the remote. Returns `false` on 404 (the
56
+ * conventional "tag not found" signal), `true` on 200, throws on other
57
+ * errors so callers can surface "API down" vs "tag missing" distinctly.
58
+ */
59
+ export async function checkTagExists(
60
+ repoUrl: string,
61
+ tag: string,
62
+ opts: GitHubFetchOpts = {},
63
+ ): Promise<boolean> {
64
+ const { owner, repo } = parseGitHubRepoUrl(repoUrl)
65
+ const fetchImpl = opts.fetchImpl ?? fetch
66
+ const timeoutMs = opts.timeoutMs ?? 10_000
67
+ const r = await fetchImpl(
68
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tag)}`,
69
+ {
70
+ headers: { Accept: 'application/vnd.github+json' },
71
+ signal: AbortSignal.timeout(timeoutMs),
72
+ },
73
+ )
74
+ if (r.status === 404) return false
75
+ if (!r.ok) {
76
+ throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/git/refs/tags/${tag}`)
77
+ }
78
+ return true
79
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Local accessor for the cached PROFILE scope key.
3
+ *
4
+ * Wraps `getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)` with the
5
+ * hex-encoding the gateway handoff envelopes expect. Used by `nebula upgrade`
6
+ * (both `--reprovision` + in-place) to ship the cached key to the new sandbox
7
+ * daemon so it boots with `slots.profile` ready to anchor instead of
8
+ * `{ status: 'skipped', reason: 'no-profile-key' }`.
9
+ *
10
+ * Returns undefined when the operator session is absent / expired / missing
11
+ * the PROFILE scope (pre-v0.23.1 agents). Callers should surface a one-line
12
+ * note in that case so the operator knows to refresh the session before the
13
+ * next upgrade.
14
+ */
15
+
16
+ import { OPERATOR_BLOB_SCOPES, getSessionKey } from 'nebula-ai-core'
17
+
18
+ export function loadProfileScopeKeyHex(agentId: string): `0x${string}` | undefined {
19
+ try {
20
+ const buf = getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)
21
+ return buf ? (`0x${buf.toString('hex')}` as `0x${string}`) : undefined
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
@@ -0,0 +1,55 @@
1
+ import { type GitHubFetchOpts, resolveLatestRelease } from './github-releases'
2
+
3
+ /** Canonical nebula repo. Override via {@link ResolveNebulaRefOpts.repoUrl}. */
4
+ export const NEBULA_REPO_URL = 'https://github.com/rstfulzz/nebula.git'
5
+
6
+ /** Magic ref keyword that triggers GitHub `releases/latest` resolution. */
7
+ export const LATEST_KEYWORD = 'latest'
8
+
9
+ export interface ResolvedRef {
10
+ ref: string
11
+ /** True if `ref` looks like `vX.Y.Z`. Drives pre/post-flight version verification. */
12
+ isTag: boolean
13
+ /** True if user said `latest` and we resolved via API — caller skips pre-flight (already source of truth). */
14
+ resolvedFromLatest: boolean
15
+ }
16
+
17
+ export interface ResolveNebulaRefOpts extends GitHubFetchOpts {
18
+ repoUrl?: string
19
+ /** Test seam. Defaults to `process.env`. */
20
+ env?: Record<string, string | undefined>
21
+ }
22
+
23
+ const TAG_RE = /^v\d+\.\d+\.\d+/
24
+
25
+ /**
26
+ * Resolve user ref. Priority: rawRef → NEBULA_BOOTSTRAP_REF env → `latest`.
27
+ * Tag-shaped refs pass through. Branch / SHA refs return isTag=false (no
28
+ * version verification possible).
29
+ */
30
+ export async function resolveNebulaRef(
31
+ rawRef: string | undefined,
32
+ opts: ResolveNebulaRefOpts = {},
33
+ ): Promise<ResolvedRef> {
34
+ const env = opts.env ?? process.env
35
+ const arg = rawRef ?? env.NEBULA_BOOTSTRAP_REF ?? LATEST_KEYWORD
36
+
37
+ if (arg === LATEST_KEYWORD) {
38
+ const release = await resolveLatestRelease(opts.repoUrl ?? NEBULA_REPO_URL, opts)
39
+ return { ref: release.tagName, isTag: true, resolvedFromLatest: true }
40
+ }
41
+ if (TAG_RE.test(arg)) {
42
+ return { ref: arg, isTag: true, resolvedFromLatest: false }
43
+ }
44
+ return { ref: arg, isTag: false, resolvedFromLatest: false }
45
+ }
46
+
47
+ /** Pretty form of a ResolvedRef for prompts and outros. Adds `(resolved from latest)` suffix when applicable. */
48
+ export function formatResolvedRef(resolved: ResolvedRef): string {
49
+ return resolved.resolvedFromLatest ? `${resolved.ref} (resolved from latest)` : resolved.ref
50
+ }
51
+
52
+ /** Expected `package.json` version for a ResolvedRef, or null when no strict expectation (branch / SHA). */
53
+ export function expectedVersionFromRef(resolved: ResolvedRef): string | null {
54
+ return resolved.isTag ? resolved.ref.replace(/^v/, '') : null
55
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Run `fn` with `console.log/info/warn/error/debug` swapped for no-ops so they
3
+ * cannot interleave with clack's in-place spinner re-render. Originals are
4
+ * restored even if `fn` throws.
5
+ *
6
+ * Why: Mantle Storage SDK and Mantle Compute broker SDK both `console.log` directly
7
+ * during their work (selected nodes, upload progress, broker tx hashes, etc).
8
+ * When a clack spinner is running, every leaked log line pushes the spinner
9
+ * down and the next animation frame draws a new spinner row, creating the
10
+ * "100x stacked spinner" visual we saw on the WC init test. Suppressing these
11
+ * during the spinner-active phases keeps the wizard output clean.
12
+ *
13
+ * Note: `chat.tsx` does its own process-lifetime console redirect to a chat
14
+ * log file. That cannot use this helper because its lifetime is the whole
15
+ * session, not a scoped wrap. Keep the two pathways separate.
16
+ */
17
+ export async function withSilencedConsole<T>(fn: () => Promise<T>): Promise<T> {
18
+ const orig = {
19
+ log: console.log,
20
+ info: console.info,
21
+ warn: console.warn,
22
+ error: console.error,
23
+ debug: console.debug,
24
+ }
25
+ const noop = (() => {}) as (...args: unknown[]) => void
26
+ console.log = noop as typeof console.log
27
+ console.info = noop as typeof console.info
28
+ console.warn = noop as typeof console.warn
29
+ console.error = noop as typeof console.error
30
+ console.debug = noop as typeof console.debug
31
+ try {
32
+ return await fn()
33
+ } finally {
34
+ console.log = orig.log
35
+ console.info = orig.info
36
+ console.warn = orig.warn
37
+ console.error = orig.error
38
+ console.debug = orig.debug
39
+ }
40
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Local persistence for telegram bot secrets, encrypted via the operator's
3
+ * sign-derived AEAD key (scope `OPERATOR_BLOB_SCOPES.TELEGRAM`).
4
+ *
5
+ * On-disk file: `~/.nebula/agents/<id>/telegram-secrets.encrypted`
6
+ *
7
+ * {
8
+ * version: 2,
9
+ * scope: 'nebula-telegram-v1',
10
+ * blob: <base64(iv|tag|ciphertext)>,
11
+ * }
12
+ *
13
+ * Plaintext shape inside the blob:
14
+ *
15
+ * {
16
+ * botToken: string, // from @BotFather
17
+ * botUsername?: string, // cached at setup-time getMe
18
+ * botId?: number, // cached at setup-time getMe
19
+ * allowedUserIds: number[],
20
+ * }
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
24
+ import { dirname, join } from 'node:path'
25
+ import {
26
+ OPERATOR_BLOB_SCOPES,
27
+ type OperatorEncryptedBlob,
28
+ type OperatorSigner,
29
+ agentPaths,
30
+ decodeOperatorBlobBytes,
31
+ decryptOperatorBlob,
32
+ encodeOperatorBlobBytes,
33
+ encryptOperatorBlob,
34
+ } from 'nebula-ai-core'
35
+ import type { Address } from 'viem'
36
+
37
+ export interface TelegramSecretsPlaintext {
38
+ botToken: string
39
+ botUsername?: string
40
+ botId?: number
41
+ allowedUserIds: number[]
42
+ }
43
+
44
+ /**
45
+ * Subset of `TelegramSecretsPlaintext` that the CLI ships into the harness
46
+ * provision envelope on init / upgrade / resume. The harness doesn't need
47
+ * the operator-side metadata (`botUsername`, `botId`); it only needs the
48
+ * token + allowlist + optional pairing list. Centralising this as a named
49
+ * type avoids drift between the four CLI handoff sites that previously
50
+ * inlined the literal shape (init wizard, upgrade, resume, the
51
+ * `HandoffAgentToGateway` + `ResumeArchivedSandbox` interfaces).
52
+ */
53
+ export interface TelegramHandoffSecrets {
54
+ botToken: string
55
+ allowedUserIds: number[]
56
+ pairingApproved?: number[]
57
+ }
58
+
59
+ export function telegramSecretsPath(agentId: string): string {
60
+ return join(agentPaths.agent(agentId).dir, 'telegram-secrets.encrypted')
61
+ }
62
+
63
+ export function telegramSecretsExist(agentId: string): boolean {
64
+ return existsSync(telegramSecretsPath(agentId))
65
+ }
66
+
67
+ export async function loadTelegramSecrets(opts: {
68
+ signer: OperatorSigner
69
+ agentAddress: Address
70
+ agentId: string
71
+ }): Promise<TelegramSecretsPlaintext | null> {
72
+ const path = telegramSecretsPath(opts.agentId)
73
+ if (!existsSync(path)) return null
74
+ const fileBytes = await readFile(path)
75
+ const blob: OperatorEncryptedBlob = decodeOperatorBlobBytes(new Uint8Array(fileBytes))
76
+ const ptBytes = await decryptOperatorBlob({
77
+ signer: opts.signer,
78
+ scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
79
+ agentAddress: opts.agentAddress,
80
+ blob,
81
+ })
82
+ const parsed = JSON.parse(new TextDecoder().decode(ptBytes)) as TelegramSecretsPlaintext
83
+ if (typeof parsed.botToken !== 'string' || !Array.isArray(parsed.allowedUserIds)) {
84
+ throw new Error('telegram-secrets: malformed plaintext (missing botToken or allowedUserIds)')
85
+ }
86
+ return parsed
87
+ }
88
+
89
+ /**
90
+ * Load + project telegram secrets into the shape the gateway provision envelope
91
+ * expects. Used by every sandbox-handoff flow that ships TG (init/upgrade/
92
+ * resume/deploy/chat-sandbox auto-resume); centralises the try/decrypt/swallow
93
+ * pattern so future TG-secret schema changes touch one place.
94
+ *
95
+ * Errors are non-fatal: TG is opt-in. Failure fires `onNotice` (so the operator
96
+ * sees the reason in the spinner) and returns undefined.
97
+ *
98
+ * `chat.tsx` keeps its own loader because it needs the full plaintext (including
99
+ * `botUsername` for the unlock spinner UX).
100
+ */
101
+ export async function loadTelegramHandoffSecrets(opts: {
102
+ signer: OperatorSigner
103
+ agentAddress: Address
104
+ agentId: string
105
+ onNotice?: (msg: string) => void
106
+ }): Promise<TelegramHandoffSecrets | undefined> {
107
+ const agentId = opts.agentId
108
+ try {
109
+ const tg = await loadTelegramSecrets({
110
+ signer: opts.signer,
111
+ agentAddress: opts.agentAddress,
112
+ agentId,
113
+ })
114
+ if (!tg) return undefined
115
+ return { botToken: tg.botToken, allowedUserIds: tg.allowedUserIds }
116
+ } catch (err) {
117
+ opts.onNotice?.(`telegram secrets read failed: ${(err as Error).message.slice(0, 120)}`)
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ export async function saveTelegramSecrets(opts: {
123
+ signer: OperatorSigner
124
+ agentAddress: Address
125
+ agentId: string
126
+ plaintext: TelegramSecretsPlaintext
127
+ /**
128
+ * v0.24.3: pre-derived TELEGRAM scope key (32 bytes). The init wizard
129
+ * derives this once and passes it both here AND into `.operator-session`,
130
+ * so encryptOperatorBlob skips the redundant sign it would otherwise make.
131
+ * Threads through to encryptOperatorBlob; see that helper for fallback.
132
+ */
133
+ precomputedKey?: Buffer
134
+ }): Promise<void> {
135
+ const path = telegramSecretsPath(opts.agentId)
136
+ await mkdir(dirname(path), { recursive: true })
137
+ const ptBytes = new TextEncoder().encode(JSON.stringify(opts.plaintext))
138
+ const blob = await encryptOperatorBlob({
139
+ signer: opts.signer,
140
+ scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
141
+ agentAddress: opts.agentAddress,
142
+ plaintext: ptBytes,
143
+ precomputedKey: opts.precomputedKey,
144
+ })
145
+ await writeFile(path, encodeOperatorBlobBytes(blob))
146
+ }
147
+
148
+ export async function removeTelegramSecrets(agentId: string): Promise<boolean> {
149
+ const path = telegramSecretsPath(agentId)
150
+ if (!existsSync(path)) return false
151
+ await rm(path, { force: true })
152
+ return true
153
+ }
154
+
155
+ const BOT_TOKEN_RE = /^\d{6,15}:[A-Za-z0-9_-]{30,}$/
156
+
157
+ export function looksLikeBotToken(s: string): boolean {
158
+ return BOT_TOKEN_RE.test(s.trim())
159
+ }
160
+
161
+ export interface ValidatedBotInfo {
162
+ id: number
163
+ username: string
164
+ firstName: string
165
+ }
166
+
167
+ /**
168
+ * Telegram Bot API getMe — cheap, free, no message side-effect. Used by
169
+ * `nebula telegram setup` to validate the token before persisting it AND by
170
+ * `nebula telegram status` to confirm the stored token still works.
171
+ *
172
+ * Throws on non-200 / `ok: false` with a clean error message; caller wraps
173
+ * the throw in a clack spinner.stop().
174
+ */
175
+ export async function fetchBotInfo(
176
+ botToken: string,
177
+ opts?: { signal?: AbortSignal },
178
+ ): Promise<ValidatedBotInfo> {
179
+ const url = `https://api.telegram.org/bot${encodeURIComponent(botToken)}/getMe`
180
+ const res = await fetch(url, { signal: opts?.signal })
181
+ if (!res.ok) {
182
+ throw new Error(`getMe HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
183
+ }
184
+ const body = (await res.json()) as {
185
+ ok: boolean
186
+ description?: string
187
+ result?: { id: number; username?: string; first_name?: string }
188
+ }
189
+ if (!body.ok || !body.result) {
190
+ throw new Error(`getMe rejected: ${body.description ?? 'unknown error'}`)
191
+ }
192
+ if (!body.result.username) throw new Error('bot has no username; create one in @BotFather')
193
+ return {
194
+ id: body.result.id,
195
+ username: body.result.username,
196
+ firstName: body.result.first_name ?? body.result.username,
197
+ }
198
+ }
199
+
200
+ export function parseAllowedUserIds(
201
+ input: string,
202
+ ): { ok: true; ids: number[] } | { ok: false; reason: string } {
203
+ const trimmed = input.trim()
204
+ if (trimmed.length === 0) return { ok: true, ids: [] }
205
+ const parts = trimmed
206
+ .split(/[,\s]+/)
207
+ .map(p => p.trim())
208
+ .filter(p => p.length > 0)
209
+ const ids: number[] = []
210
+ for (const p of parts) {
211
+ if (!/^\d+$/.test(p)) return { ok: false, reason: `not a numeric id: "${p}"` }
212
+ const n = Number(p)
213
+ if (!Number.isFinite(n) || n <= 0) return { ok: false, reason: `not a positive id: "${p}"` }
214
+ ids.push(n)
215
+ }
216
+ // Dedupe, preserve first-seen order.
217
+ return { ok: true, ids: [...new Set(ids)] }
218
+ }