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,90 @@
1
+ import { cancel, confirm, intro, isCancel, log, outro, spinner } from '@clack/prompts'
2
+ import { NETWORK_RPC, drainAgentEOA, explorerTxUrl } from 'nebula-ai-core'
3
+ import { http, type Address, createPublicClient, formatEther, isAddress } from 'viem'
4
+ import { findAndLoadConfig } from '../config/load'
5
+ import { withSilencedConsole } from '../util/silence-console'
6
+ import { unlockAgentSigner } from './_unlock'
7
+
8
+ export interface DrainOpts {
9
+ /** Target address. If omitted, defaults to the operator wallet on this config. */
10
+ to?: string
11
+ /** Skip the destructive confirmation prompt. */
12
+ yes?: boolean
13
+ }
14
+
15
+ export async function runDrain(opts: DrainOpts): Promise<void> {
16
+ intro('nebula drain')
17
+
18
+ const loaded = await findAndLoadConfig()
19
+ if (!loaded) {
20
+ cancel('No nebula.config.ts found. Run `nebula init` first.')
21
+ return
22
+ }
23
+ const { config } = loaded
24
+ if (!config.identity.agent) {
25
+ cancel('Config has no agent. Run `nebula init` first.')
26
+ return
27
+ }
28
+
29
+ const network = config.network
30
+ const agentAddress = config.identity.agent as Address
31
+
32
+ const targetRaw = opts.to ?? (config.identity.operator as string | undefined)
33
+ if (!targetRaw) {
34
+ cancel('No --to address provided and config has no operator. Pass --to <0x...>.')
35
+ return
36
+ }
37
+ if (!isAddress(targetRaw)) {
38
+ cancel(`--to is not a valid address: ${targetRaw}`)
39
+ return
40
+ }
41
+ const to = targetRaw as Address
42
+
43
+ const publicClient = createPublicClient({ transport: http(NETWORK_RPC[network]) })
44
+ const before = await publicClient.getBalance({ address: agentAddress })
45
+ log.info(
46
+ [
47
+ `agent ${agentAddress}`,
48
+ `balance ${formatEther(before)} Mantle`,
49
+ `target ${to}`,
50
+ `network ${network}`,
51
+ ].join('\n'),
52
+ )
53
+
54
+ if (before === 0n) {
55
+ log.warn('Agent EOA already empty.')
56
+ outro('nothing to drain')
57
+ return
58
+ }
59
+
60
+ if (!opts.yes) {
61
+ const ok = (await confirm({
62
+ message: `Sweep agent EOA balance (${formatEther(before)} Mantle minus gas) to ${to}?`,
63
+ initialValue: false,
64
+ })) as boolean | symbol
65
+ if (isCancel(ok) || !ok) {
66
+ cancel('Aborted.')
67
+ return
68
+ }
69
+ }
70
+
71
+ const unlocked = await unlockAgentSigner(config)
72
+ if (!unlocked) return
73
+ try {
74
+ const sSweep = spinner()
75
+ sSweep.start(`Sweeping agent EOA → ${to}`)
76
+ try {
77
+ const result = await withSilencedConsole(() =>
78
+ drainAgentEOA({ network, privkeyHex: unlocked.agentPrivkey, to }),
79
+ )
80
+ sSweep.stop(
81
+ `swept ${formatEther(result.amountSent)} Mantle (gas reserved ${formatEther(result.gasReserved)} Mantle) → ${explorerTxUrl(network, result.txHash)}`,
82
+ )
83
+ outro(`agent ${agentAddress} drained to ${to}`)
84
+ } catch (e) {
85
+ sSweep.stop(`sweep failed: ${(e as Error).message.slice(0, 160)}`)
86
+ }
87
+ } finally {
88
+ await unlocked.close()
89
+ }
90
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `nebula gateway logs [--tail N] [-f]` — tail the gateway log.
3
+ *
4
+ * v0.19.x: gateway daemon logs to stdout/stderr only (inherited by `gateway run`
5
+ * or backgrounded by `gateway start`). v0.19.3 wires a log file at
6
+ * `~/.nebula/agents/<id>/gateway.log` for tailing. Until then, this command
7
+ * informs the user where to look.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { existsSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { agentPaths, placeholderAgentId } from 'nebula-ai-core'
14
+ import { findAndLoadConfig } from '../config/load'
15
+
16
+ export interface GatewayLogsOpts {
17
+ agentId?: string
18
+ tail: number
19
+ follow: boolean
20
+ }
21
+
22
+ export async function runGatewayLogs(opts: GatewayLogsOpts): Promise<void> {
23
+ let agentId = opts.agentId
24
+ if (!agentId) {
25
+ const found = await findAndLoadConfig()
26
+ if (!found?.config) {
27
+ console.error('nebula gateway logs: no nebula.config.ts and no --agent provided')
28
+ process.exit(1)
29
+ }
30
+ const agentEoa = found.config.identity.agent
31
+ if (!agentEoa) {
32
+ console.error('nebula gateway logs: config has no agent EOA; run `nebula init` first')
33
+ process.exit(1)
34
+ }
35
+ agentId = placeholderAgentId(agentEoa)
36
+ }
37
+ const logFile = join(agentPaths.agent(agentId).dir, 'gateway.log')
38
+ if (!existsSync(logFile)) {
39
+ console.log(`gateway log not found at ${logFile}`)
40
+ console.log('v0.19.x: gateway daemon logs to stdout when run via `nebula gateway run`.')
41
+ console.log(
42
+ 'Background it with: nohup bun packages/gateway/bin/nebula-gateway-local > ~/nebula-logs/gateway.log 2>&1 &',
43
+ )
44
+ return
45
+ }
46
+ const args = ['-n', String(opts.tail), ...(opts.follow ? ['-f'] : []), logFile]
47
+ const proc = spawn('tail', args, { stdio: 'inherit' })
48
+ proc.on('exit', code => process.exit(code ?? 0))
49
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `nebula gateway run` — foreground daemon (blocks; Ctrl+C to stop).
3
+ *
4
+ * Spawns `nebula-gateway-local` (the bin in nebula-ai-gateway) with
5
+ * inherit stdio so the user sees logs live. Reads operator-session for the
6
+ * cached AES keys; fails loud if no session exists ("run nebula gateway start
7
+ * first").
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { join } from 'node:path'
12
+ import { resolveLocalBin } from '../util/gateway-spawn'
13
+
14
+ export interface GatewayRunOpts {
15
+ agentId?: string
16
+ }
17
+
18
+ export async function runGatewayForeground(opts: GatewayRunOpts): Promise<void> {
19
+ const env = { ...process.env }
20
+ if (opts.agentId) env.NEBULA_AGENT_ID = opts.agentId
21
+ // Default NEBULA_CONFIG to ~/.nebula/config.ts if not already set.
22
+ if (!env.NEBULA_CONFIG) {
23
+ env.NEBULA_CONFIG = join(env.HOME ?? '', '.nebula', 'config.ts')
24
+ }
25
+
26
+ const localBin = resolveLocalBin()
27
+ const proc = spawn('bun', [localBin], {
28
+ env,
29
+ stdio: 'inherit',
30
+ })
31
+ proc.on('exit', code => process.exit(code ?? 0))
32
+ proc.on('error', err => {
33
+ console.error(`nebula gateway run: spawn failed — ${err.message}`)
34
+ process.exit(1)
35
+ })
36
+
37
+ const forwardSignal = (sig: NodeJS.Signals): void => {
38
+ if (!proc.killed) proc.kill(sig)
39
+ }
40
+ process.on('SIGINT', () => forwardSignal('SIGINT'))
41
+ process.on('SIGTERM', () => forwardSignal('SIGTERM'))
42
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `nebula gateway start` — interactive Touch ID + write operator-session,
3
+ * then fork the gateway daemon detached.
4
+ *
5
+ * Flow:
6
+ * 1. Load config from ~/.nebula/config.ts
7
+ * 2. Resolve agentId (override via --agent or first agent in config)
8
+ * 3. Check if gateway already running (lock file). If yes, error.
9
+ * 4. Pick operator signer + interactive Touch ID via existing operator-picker
10
+ * 5. Pre-derive scope keys via precomputeAllScopes (keystore + telegram)
11
+ * 6. Write operator-session file (perm 0600, 24h TTL)
12
+ * 7. Spawn nebula-gateway-local detached + wait for socket to become readable
13
+ * (proves the daemon booted cleanly)
14
+ * 8. Print pid + socket path
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { spinner } from '@clack/prompts'
20
+ import {
21
+ OPERATOR_BLOB_SCOPES,
22
+ type OperatorBlobScope,
23
+ agentPaths,
24
+ buildOperatorSession,
25
+ decodeKeystoreBytes,
26
+ decodeOperatorBlobBytes,
27
+ isOperatorSessionComplete,
28
+ placeholderAgentId,
29
+ precomputeAllScopes,
30
+ readOperatorSession,
31
+ requiredScopesForAgent,
32
+ tryDecryptKeystoreWithKey,
33
+ tryDecryptOperatorBlobWithKey,
34
+ writeOperatorSession,
35
+ } from 'nebula-ai-core'
36
+ import { type Address, getAddress } from 'viem'
37
+ import { findAndLoadConfig } from '../config/load'
38
+ import { spawnGatewayDaemon } from '../util/gateway-spawn'
39
+ import { telegramSecretsPath } from '../util/telegram-secrets'
40
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
41
+
42
+ export interface GatewayStartOpts {
43
+ agentId?: string
44
+ }
45
+
46
+ export async function runGatewayStart(opts: GatewayStartOpts): Promise<void> {
47
+ const found = await findAndLoadConfig()
48
+ if (!found?.config) {
49
+ console.error('nebula gateway start: no nebula.config.ts found in cwd or ~/.nebula/')
50
+ process.exit(1)
51
+ }
52
+ const config = found.config
53
+ const agentAddress = getAddress(config.identity.agent as Address)
54
+ const agentId = opts.agentId ?? placeholderAgentId(agentAddress)
55
+ const paths = agentPaths.agent(agentId)
56
+ const socketPath = join(paths.dir, 'gateway.sock')
57
+
58
+ // v0.23.2: if the socket exists, check for version drift. If the running
59
+ // daemon's version differs from the on-disk CLI binary, auto-restart so
60
+ // operators don't have to remember `nebula gateway restart` after every
61
+ // `bun add -g nebula-ai-cli@N`. If versions match, bail with the
62
+ // legacy "already running" error.
63
+ if (existsSync(socketPath)) {
64
+ const { createHash } = await import('node:crypto')
65
+ const { homedir } = await import('node:os')
66
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
67
+ const lockFile = join(homedir(), '.nebula', 'locks', `nebula-gateway-${identityHash}.lock`)
68
+ const { ensureGatewayVersionMatchesCli } = await import('../util/gateway-version')
69
+ const drift = await ensureGatewayVersionMatchesCli({ socketPath, lockFile })
70
+ if (drift.action === 'ok' || drift.action === 'no-cli-version') {
71
+ console.error(
72
+ `nebula gateway start: socket already exists at ${socketPath} — gateway may be running (version ${drift.daemonVersion ?? 'unknown'}). Try \`nebula gateway stop\` first.`,
73
+ )
74
+ process.exit(1)
75
+ }
76
+ console.log(`note: ${drift.note}`)
77
+ }
78
+
79
+ // v0.21.12: derive the set of scope keys this agent's daemon will need
80
+ // based on what's on disk (always 'keystore'; adds 'telegram' when
81
+ // telegram-secrets.encrypted is present, etc.). The cached session is only
82
+ // "complete enough to skip Touch ID" when it contains every required key.
83
+ // Pre-fix, this used the binary `isOperatorSessionFresh` which returned
84
+ // true for any non-expired session, even one written by a path that didn't
85
+ // derive TELEGRAM. The daemon then booted, found no telegram scope key,
86
+ // and silently dropped all inbound TG messages.
87
+ const required = requiredScopesForAgent(agentId)
88
+ const extraScopes = required.filter((s): s is Exclude<typeof s, 'keystore'> => s !== 'keystore')
89
+ const complete = isOperatorSessionComplete(agentId, required)
90
+ if (!complete) {
91
+ const sUnlock = spinner()
92
+ sUnlock.start('Unlocking operator wallet for session-key derivation')
93
+ let operator: Awaited<ReturnType<typeof loadOrPickOperatorSigner>>
94
+ try {
95
+ operator = await loadOrPickOperatorSigner({
96
+ network: config.network,
97
+ hint: config.operator,
98
+ })
99
+ } catch (e) {
100
+ sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
101
+ process.exit(1)
102
+ }
103
+ if (!operator) {
104
+ sUnlock.stop('operator unlock cancelled')
105
+ process.exit(1)
106
+ }
107
+
108
+ sUnlock.message(`Deriving scope keys (${required.join(' + ')})`)
109
+ try {
110
+ // v0.24.10: verify each derived canonical key against the on-disk
111
+ // encrypted artifact. If verify fails (e.g. fox's pre-v0.24.9 WC
112
+ // keystore was encrypted with the legacy empty-EIP712Domain hash),
113
+ // `precomputeAllScopes` falls back to the legacy variant via the WC
114
+ // signer's escape hatch and caches the WORKING key. Without this,
115
+ // the daemon would boot with a stale canonical key + the
116
+ // `precomputedKey skips fallback` semantic and panic on first
117
+ // AES-GCM decrypt.
118
+ const verifyKey = buildKeystoreVerifier(agentId)
119
+ const keys = await precomputeAllScopes(operator, agentAddress, extraScopes, { verifyKey })
120
+ const sess = buildOperatorSession({ agent: agentAddress, keys })
121
+ writeOperatorSession(agentId, sess)
122
+ sUnlock.stop('operator-session written (24h TTL)')
123
+ } catch (e) {
124
+ sUnlock.stop(`derive failed: ${(e as Error).message.slice(0, 160)}`)
125
+ await operator.close?.()
126
+ process.exit(1)
127
+ }
128
+ await operator.close?.()
129
+ } else {
130
+ console.log(`operator-session complete (${required.join(' + ')}); skipping Touch ID`)
131
+ }
132
+
133
+ // Spawn gateway daemon detached. Inherit stdio for the first ~3s so the
134
+ // user sees boot errors, then redirect to log file when ready.
135
+ const sBoot = spinner()
136
+ sBoot.start(`Spawning gateway daemon (agent=${agentId.slice(0, 8)}…)`)
137
+
138
+ const result = await spawnGatewayDaemon({
139
+ agentId,
140
+ configPath: found.path ?? '',
141
+ socketPath,
142
+ timeoutMs: 10_000,
143
+ // v0.21.12: redirect daemon stdout/stderr to gateway.log (default
144
+ // 'log-file' mode) so boot errors survive the parent's exit. Operators
145
+ // see the log via `nebula gateway logs` or by tailing
146
+ // ~/.nebula/agents/<id>/gateway.log directly.
147
+ })
148
+ if (result.ready) {
149
+ sBoot.stop(`gateway running pid=${result.pid} socket=${socketPath}`)
150
+ console.log('stop with: nebula gateway stop')
151
+ console.log('logs: nebula gateway logs -f')
152
+ } else {
153
+ const reason = result.reason ?? 'unknown'
154
+ const detail = result.error ? `: ${result.error}` : ''
155
+ sBoot.stop(
156
+ `gateway did not bind socket within 10s (reason=${reason} pid=${result.pid ?? '?'})${detail}; check above output`,
157
+ )
158
+ process.exit(1)
159
+ }
160
+ }
161
+
162
+ // Stub — wired by gateway-status when needed.
163
+ export function _operatorSessionPresent(agentId: string): boolean {
164
+ return readOperatorSession(agentId) !== null
165
+ }
166
+
167
+ /**
168
+ * v0.24.10: returns a verifier that `precomputeAllScopes` calls after each
169
+ * canonical key derive. The verifier:
170
+ *
171
+ * - For 'keystore': trial-decrypts `<agentDir>/keystore.json` with the
172
+ * candidate key. Returns true on success, false on AES-GCM auth failure.
173
+ * False triggers the legacy empty-EIP712Domain fallback inside
174
+ * `precomputeAllScopes` so pre-v0.24.9 WC-init'd keystores (only known
175
+ * instance is fox, tokenId #5) can still flip to the correct AES key on
176
+ * first boot under v0.24.10+.
177
+ *
178
+ * - For TELEGRAM: trial-decrypts `<agentDir>/telegram-secrets.encrypted`
179
+ * when present. Same legacy-fallback semantic.
180
+ *
181
+ * - For PROFILE / unknown: returns true unconditionally. PROFILE has no
182
+ * on-disk artifact to verify against (the encrypted blob lives in iNFT
183
+ * slot 3 on chain); the keystore-scope detection above already cascades
184
+ * the legacy flag to PROFILE via `precomputeAllScopes`'s
185
+ * `useLegacyForRest` branch, so the PROFILE key is derived via the
186
+ * matching variant without needing a verify here.
187
+ *
188
+ * - On missing keystore (init flow never reached this code path, so this
189
+ * is a defensive fallback): returns true so the derive completes; the
190
+ * daemon's own decrypt at boot will surface the real error.
191
+ */
192
+ function buildKeystoreVerifier(agentId: string) {
193
+ const keystorePath = agentPaths.agent(agentId).keystore
194
+ const tgSecretsPath = telegramSecretsPath(agentId)
195
+ return async (scope: 'keystore' | OperatorBlobScope, key: Buffer): Promise<boolean> => {
196
+ if (scope === 'keystore') {
197
+ if (!existsSync(keystorePath)) return true
198
+ try {
199
+ const ks = decodeKeystoreBytes(new TextEncoder().encode(readFileSync(keystorePath, 'utf8')))
200
+ return tryDecryptKeystoreWithKey(ks, key)
201
+ } catch {
202
+ return true
203
+ }
204
+ }
205
+ if (scope === OPERATOR_BLOB_SCOPES.TELEGRAM) {
206
+ if (!existsSync(tgSecretsPath)) return true
207
+ try {
208
+ const blob = decodeOperatorBlobBytes(new Uint8Array(readFileSync(tgSecretsPath)))
209
+ return tryDecryptOperatorBlobWithKey(blob, key, OPERATOR_BLOB_SCOPES.TELEGRAM)
210
+ } catch {
211
+ return true
212
+ }
213
+ }
214
+ return true
215
+ }
216
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `nebula gateway status` — show PID, uptime, socket path, lock state,
3
+ * operator-session freshness.
4
+ */
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import {
11
+ agentPaths,
12
+ isOperatorSessionFresh,
13
+ placeholderAgentId,
14
+ readOperatorSession,
15
+ } from 'nebula-ai-core'
16
+ import { findAndLoadConfig } from '../config/load'
17
+
18
+ export interface GatewayStatusOpts {
19
+ agentId?: string
20
+ }
21
+
22
+ function fmtAge(ms: number): string {
23
+ const s = Math.floor(ms / 1000)
24
+ if (s < 60) return `${s}s`
25
+ const m = Math.floor(s / 60)
26
+ if (m < 60) return `${m}m`
27
+ const h = Math.floor(m / 60)
28
+ return `${h}h${m % 60}m`
29
+ }
30
+
31
+ export async function runGatewayStatus(opts: GatewayStatusOpts): Promise<void> {
32
+ let agentId = opts.agentId
33
+ if (!agentId) {
34
+ const found = await findAndLoadConfig()
35
+ if (!found?.config) {
36
+ console.error('nebula gateway status: no nebula.config.ts and no --agent provided')
37
+ process.exit(1)
38
+ }
39
+ const agentEoa = found.config.identity.agent
40
+ if (!agentEoa) {
41
+ console.error('nebula gateway status: config has no agent EOA; run `nebula init` first')
42
+ process.exit(1)
43
+ }
44
+ agentId = placeholderAgentId(agentEoa)
45
+ }
46
+ const paths = agentPaths.agent(agentId)
47
+ const socketPath = join(paths.dir, 'gateway.sock')
48
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
49
+ const lockFile = join(homedir(), '.nebula', 'locks', `nebula-gateway-${identityHash}.lock`)
50
+
51
+ console.log(`agent: ${agentId}`)
52
+ console.log(`socket: ${socketPath} ${existsSync(socketPath) ? '(present)' : '(absent)'}`)
53
+ console.log(`lock: ${lockFile} ${existsSync(lockFile) ? '(present)' : '(absent)'}`)
54
+
55
+ // PID + uptime via lock file.
56
+ if (existsSync(lockFile)) {
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(lockFile, 'utf8')) as { pid?: number }
59
+ if (typeof parsed.pid === 'number') {
60
+ let alive = false
61
+ try {
62
+ process.kill(parsed.pid, 0)
63
+ alive = true
64
+ } catch {
65
+ /* dead */
66
+ }
67
+ const stat = statSync(lockFile)
68
+ const ageMs = Date.now() - stat.mtimeMs
69
+ console.log(`pid: ${parsed.pid} ${alive ? '(alive)' : '(dead — stale lock)'}`)
70
+ console.log(`lock-age: ${fmtAge(ageMs)}`)
71
+ }
72
+ } catch {
73
+ console.log('pid: (lock file unreadable)')
74
+ }
75
+ } else {
76
+ console.log('pid: (not running)')
77
+ }
78
+
79
+ // Operator-session freshness.
80
+ const fresh = isOperatorSessionFresh(agentId)
81
+ console.log(`session: ${fresh ? 'fresh' : 'absent or expired'}`)
82
+ if (fresh) {
83
+ const sess = readOperatorSession(agentId)
84
+ if (sess) {
85
+ const remaining = sess.expiresAt - Date.now()
86
+ const scopes = Object.keys(sess.keys).filter(k => sess.keys[k as keyof typeof sess.keys])
87
+ console.log(`session-ttl: ${fmtAge(remaining)} remaining (scopes: ${scopes.join(', ')})`)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `nebula gateway stop` — SIGTERM the running gateway daemon via the lock
3
+ * file's PID. Falls through to SIGKILL after a 5s grace period.
4
+ */
5
+
6
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { agentPaths, placeholderAgentId } from 'nebula-ai-core'
10
+ import { findAndLoadConfig } from '../config/load'
11
+
12
+ export interface GatewayStopOpts {
13
+ agentId?: string
14
+ }
15
+
16
+ function lockPath(_agentId: string): string {
17
+ // Mirror packages/core/src/locks.ts — `~/.nebula/locks/<scope>-<sha256(identity).slice(0,16)>.lock`
18
+ // For 'nebula-gateway' scope. We compute the same hash as the lock module.
19
+ // Easiest: read all lock files and find one matching the agent.
20
+ return join(homedir(), '.nebula', 'locks')
21
+ }
22
+
23
+ function findGatewayLock(agentId: string): string | null {
24
+ // The lock filename embeds sha256(agentId).slice(0, 16). Compute it.
25
+ const { createHash } = require('node:crypto')
26
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
27
+ const lockFile = join(lockPath(agentId), `nebula-gateway-${identityHash}.lock`)
28
+ return existsSync(lockFile) ? lockFile : null
29
+ }
30
+
31
+ export async function runGatewayStop(opts: GatewayStopOpts): Promise<void> {
32
+ let agentId = opts.agentId
33
+ if (!agentId) {
34
+ const found = await findAndLoadConfig()
35
+ if (!found?.config) {
36
+ console.error('nebula gateway stop: no nebula.config.ts and no --agent provided')
37
+ process.exit(1)
38
+ }
39
+ const agentEoa = found.config.identity?.agent ?? null
40
+ if (!agentEoa) {
41
+ console.error('nebula gateway stop: config has no agent EOA; run `nebula init` first')
42
+ process.exit(1)
43
+ }
44
+ agentId = placeholderAgentId(agentEoa)
45
+ const label = `agent ${agentId.slice(0, 8)}…`
46
+ const eoaLabel = ` (EOA ${agentEoa.slice(0, 6)}…${agentEoa.slice(-4)})`
47
+ const configPath = found.path ?? '<unknown>'
48
+ console.log(`nebula gateway stop → ${label}${eoaLabel}`)
49
+ console.log(` config: ${configPath}`)
50
+ console.log(
51
+ ' if this is not the agent you meant, set NEBULA_ROOT or pass --agent <id> before re-running.',
52
+ )
53
+ }
54
+ const lockFile = findGatewayLock(agentId)
55
+ if (!lockFile) {
56
+ console.log(`gateway not running (no lock at ${lockPath(agentId)})`)
57
+ return
58
+ }
59
+ let pid: number
60
+ try {
61
+ const raw = readFileSync(lockFile, 'utf8').trim()
62
+ // Lock files are JSON with shape `{pid, scope, identityHash, expiresAt}`.
63
+ const parsed = JSON.parse(raw) as { pid?: number }
64
+ if (typeof parsed.pid !== 'number') {
65
+ console.error('nebula gateway stop: lock file has no pid field')
66
+ process.exit(1)
67
+ }
68
+ pid = parsed.pid
69
+ } catch (e) {
70
+ console.error(`nebula gateway stop: lock file unreadable — ${(e as Error).message}`)
71
+ process.exit(1)
72
+ }
73
+
74
+ // Verify the PID is alive.
75
+ try {
76
+ process.kill(pid, 0)
77
+ } catch {
78
+ console.log(`gateway not running (stale lock pid=${pid}); cleaning up`)
79
+ try {
80
+ unlinkSync(lockFile)
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ return
85
+ }
86
+
87
+ // Send SIGTERM, wait up to 5s, then SIGKILL.
88
+ console.log(`stopping gateway pid=${pid} ...`)
89
+ try {
90
+ process.kill(pid, 'SIGTERM')
91
+ } catch (e) {
92
+ console.error(`nebula gateway stop: SIGTERM failed — ${(e as Error).message}`)
93
+ process.exit(1)
94
+ }
95
+
96
+ const start = Date.now()
97
+ while (Date.now() - start < 5_000) {
98
+ try {
99
+ process.kill(pid, 0)
100
+ } catch {
101
+ console.log(`gateway stopped pid=${pid}`)
102
+ // Lock file is auto-removed by daemon's shutdown handler. Belt + suspenders:
103
+ try {
104
+ if (existsSync(lockFile)) unlinkSync(lockFile)
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ // Also clean up the socket file in case the daemon didn't.
109
+ const socketPath = join(agentPaths.agent(agentId).dir, 'gateway.sock')
110
+ try {
111
+ if (existsSync(socketPath)) unlinkSync(socketPath)
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ return
116
+ }
117
+ await new Promise(r => setTimeout(r, 200))
118
+ }
119
+
120
+ console.log('gateway did not exit in 5s; sending SIGKILL')
121
+ try {
122
+ process.kill(pid, 'SIGKILL')
123
+ } catch (e) {
124
+ console.error(`nebula gateway stop: SIGKILL failed — ${(e as Error).message}`)
125
+ process.exit(1)
126
+ }
127
+ try {
128
+ if (existsSync(lockFile)) unlinkSync(lockFile)
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ console.log(`gateway force-killed pid=${pid}`)
133
+ }