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,101 @@
1
+ /**
2
+ * `nebula gateway <sub>` argv dispatcher.
3
+ *
4
+ * Subs:
5
+ * run — foreground daemon (blocks; Ctrl+C to stop). Uses operator-session.
6
+ * start — interactive: prompt Touch ID, write operator-session, fork daemon.
7
+ * stop — SIGTERM the running daemon via the lock file's PID.
8
+ * restart — stop + start.
9
+ * status — show PID, uptime, socket path, lock state.
10
+ * logs — tail the gateway log (--tail N, --follow).
11
+ *
12
+ * v0.19.x scope: run + start + stop + status. restart/logs/install/setup ship
13
+ * in v0.19.3 alongside launchd plist generator.
14
+ */
15
+
16
+ export type GatewaySub = 'run' | 'start' | 'stop' | 'restart' | 'status' | 'logs'
17
+
18
+ export interface ParsedGatewayArgs {
19
+ sub: GatewaySub
20
+ /** Optional --agent <id> override; defaults to config-derived. */
21
+ agentId?: string
22
+ /** --tail N for logs; default 100. */
23
+ tail?: number
24
+ /** --follow / -f for logs. */
25
+ follow?: boolean
26
+ }
27
+
28
+ export type ParseResult = ParsedGatewayArgs | { error: string }
29
+
30
+ export function parseGatewayArgs(argv: string[]): ParseResult {
31
+ const sub = argv[0]
32
+ if (!sub) {
33
+ return { error: 'usage: nebula gateway <run | start | stop | restart | status | logs>' }
34
+ }
35
+ if (!['run', 'start', 'stop', 'restart', 'status', 'logs'].includes(sub)) {
36
+ return { error: `unknown gateway sub: ${sub}` }
37
+ }
38
+ let agentId: string | undefined
39
+ let tail: number | undefined
40
+ let follow = false
41
+ for (let i = 1; i < argv.length; i++) {
42
+ const a = argv[i]
43
+ if (a === '--agent') {
44
+ const v = argv[++i]
45
+ if (!v) return { error: '--agent requires a value' }
46
+ agentId = v
47
+ } else if (a === '--tail') {
48
+ const v = argv[++i]
49
+ if (!v) return { error: '--tail requires a value' }
50
+ const n = Number.parseInt(v, 10)
51
+ if (!Number.isFinite(n) || n < 0) return { error: '--tail must be a positive integer' }
52
+ tail = n
53
+ } else if (a === '--follow' || a === '-f') {
54
+ follow = true
55
+ } else {
56
+ return { error: `unknown flag: ${a}` }
57
+ }
58
+ }
59
+ return { sub: sub as GatewaySub, agentId, tail, follow }
60
+ }
61
+
62
+ export async function runGateway(parsed: ParsedGatewayArgs): Promise<void> {
63
+ switch (parsed.sub) {
64
+ case 'run': {
65
+ const { runGatewayForeground } = await import('./gateway-run')
66
+ await runGatewayForeground({ agentId: parsed.agentId })
67
+ return
68
+ }
69
+ case 'start': {
70
+ const { runGatewayStart } = await import('./gateway-start')
71
+ await runGatewayStart({ agentId: parsed.agentId })
72
+ return
73
+ }
74
+ case 'stop': {
75
+ const { runGatewayStop } = await import('./gateway-stop')
76
+ await runGatewayStop({ agentId: parsed.agentId })
77
+ return
78
+ }
79
+ case 'restart': {
80
+ const { runGatewayStop } = await import('./gateway-stop')
81
+ const { runGatewayStart } = await import('./gateway-start')
82
+ await runGatewayStop({ agentId: parsed.agentId })
83
+ await runGatewayStart({ agentId: parsed.agentId })
84
+ return
85
+ }
86
+ case 'status': {
87
+ const { runGatewayStatus } = await import('./gateway-status')
88
+ await runGatewayStatus({ agentId: parsed.agentId })
89
+ return
90
+ }
91
+ case 'logs': {
92
+ const { runGatewayLogs } = await import('./gateway-logs')
93
+ await runGatewayLogs({
94
+ agentId: parsed.agentId,
95
+ tail: parsed.tail ?? 100,
96
+ follow: parsed.follow ?? false,
97
+ })
98
+ return
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `nebula identity` — ERC-8004 (Trustless Agents) on-chain agent identity.
3
+ *
4
+ * nebula identity card build + print the agent card (offline)
5
+ * nebula identity register [--name N] register the agent on the Identity Registry
6
+ * nebula identity show [<agentId>] resolve an agent (by id, or this agent's EOA)
7
+ *
8
+ * The owner of the identity NFT is the operator wallet; the card's
9
+ * `agentAddress` is the agent EOA. Registry address resolves from
10
+ * NEBULA_IDENTITY_REGISTRY env → baked-in deployment.
11
+ */
12
+ import { writeFile } from 'node:fs/promises'
13
+ import { cancel, intro, note, outro, spinner } from '@clack/prompts'
14
+ import {
15
+ NETWORK_RPC,
16
+ agentIdByAddress,
17
+ buildAgentCard,
18
+ cardToDataUri,
19
+ explorerTxUrl,
20
+ registerAgent,
21
+ resolveAgentById,
22
+ resolveRegistryAddress,
23
+ } from 'nebula-ai-core'
24
+ import { http, type Address, createPublicClient } from 'viem'
25
+ import { findAndLoadConfig } from '../config/load'
26
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
27
+
28
+ export interface IdentityArgs {
29
+ sub: 'card' | 'register' | 'show'
30
+ agentId?: string
31
+ name?: string
32
+ url?: string
33
+ out?: string
34
+ }
35
+
36
+ export function parseIdentityArgs(argv: string[]): IdentityArgs | { error: string } {
37
+ const sub = argv[0]
38
+ if (sub !== 'card' && sub !== 'register' && sub !== 'show') {
39
+ return { error: `unknown subcommand '${sub ?? '(none)'}' — try: card | register | show` }
40
+ }
41
+ const flag = (name: string): string | undefined => {
42
+ const i = argv.indexOf(name)
43
+ return i >= 0 ? argv[i + 1] : undefined
44
+ }
45
+ const positional = argv.slice(1).find(a => !a.startsWith('--'))
46
+ return {
47
+ sub,
48
+ agentId: sub === 'show' ? positional : undefined,
49
+ name: flag('--name'),
50
+ url: flag('--url'),
51
+ out: flag('--out'),
52
+ }
53
+ }
54
+
55
+ export async function runIdentity(args: IdentityArgs): Promise<void> {
56
+ const loaded = await findAndLoadConfig()
57
+ if (!loaded) {
58
+ console.error('No nebula.config.ts found. Run `nebula init` first.')
59
+ process.exit(1)
60
+ }
61
+ const { config } = loaded
62
+ const network = config.network
63
+ const agentAddress = config.identity.agent as Address | null
64
+
65
+ if (args.sub === 'card') {
66
+ if (!agentAddress) {
67
+ console.error('Config has no agent EOA. Run `nebula init` first.')
68
+ process.exit(1)
69
+ }
70
+ const card = buildAgentCard({
71
+ name: args.name ?? `nebula-${agentAddress.slice(2, 8)}`,
72
+ agentAddress,
73
+ network,
74
+ url: args.url,
75
+ })
76
+ const json = JSON.stringify(card, null, 2)
77
+ if (args.out) {
78
+ await writeFile(args.out, json, 'utf8')
79
+ console.log(`agent card written to ${args.out}`)
80
+ } else {
81
+ console.log(json)
82
+ }
83
+ return
84
+ }
85
+
86
+ const registry = resolveRegistryAddress(network)
87
+ if (!registry) {
88
+ console.error(
89
+ `No Identity Registry address for ${network}. Deploy it (contracts/script/DeployIdentityRegistry.s.sol) and set NEBULA_IDENTITY_REGISTRY.`,
90
+ )
91
+ process.exit(1)
92
+ }
93
+ const publicClient = createPublicClient({ transport: http(NETWORK_RPC[network]) })
94
+
95
+ if (args.sub === 'show') {
96
+ let id: bigint
97
+ if (args.agentId) {
98
+ id = BigInt(args.agentId)
99
+ } else {
100
+ if (!agentAddress) {
101
+ console.error(
102
+ 'Pass an <agentId>, or run `nebula init` so this agent has an EOA to reverse-resolve.',
103
+ )
104
+ process.exit(1)
105
+ }
106
+ id = await agentIdByAddress({ publicClient, registry, agentAddress })
107
+ if (id === 0n) {
108
+ console.log(
109
+ `agent ${agentAddress} is not registered on ${network}. Run \`nebula identity register\`.`,
110
+ )
111
+ return
112
+ }
113
+ }
114
+ const resolved = await resolveAgentById({ publicClient, registry, agentId: id })
115
+ console.log(`agent id ${resolved.agentId.toString()}`)
116
+ console.log(`owner ${resolved.owner}`)
117
+ console.log(`agent EOA ${resolved.agentAddress}`)
118
+ console.log(`registry ${registry} (${network})`)
119
+ console.log(
120
+ `card ${resolved.cardURI.slice(0, 80)}${resolved.cardURI.length > 80 ? '…' : ''}`,
121
+ )
122
+ return
123
+ }
124
+
125
+ // register
126
+ intro('nebula identity register')
127
+ if (!agentAddress) {
128
+ cancel('Config has no agent EOA. Run `nebula init` first.')
129
+ return
130
+ }
131
+ const existing = await agentIdByAddress({ publicClient, registry, agentAddress })
132
+ if (existing !== 0n) {
133
+ note(
134
+ `agent ${agentAddress} is already registered as id ${existing.toString()}.`,
135
+ 'already registered',
136
+ )
137
+ outro('nothing to do')
138
+ return
139
+ }
140
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
141
+ if (!operator) {
142
+ cancel('No operator wallet available; cannot register.')
143
+ return
144
+ }
145
+ const s = spinner()
146
+ s.start('Registering agent identity on the ERC-8004 registry')
147
+ try {
148
+ const walletClient = await operator.walletClient(network)
149
+ const card = buildAgentCard({
150
+ name: args.name ?? `nebula-${agentAddress.slice(2, 8)}`,
151
+ agentAddress,
152
+ network,
153
+ url: args.url,
154
+ })
155
+ const cardURI = args.url ?? cardToDataUri(card)
156
+ const { agentId, txHash } = await registerAgent({
157
+ walletClient,
158
+ publicClient,
159
+ registry,
160
+ cardURI,
161
+ agentAddress,
162
+ })
163
+ s.stop(`registered as agent id ${agentId.toString()}`)
164
+ outro(
165
+ [
166
+ ` agent id ${agentId.toString()}`,
167
+ ` registry ${registry} (${network})`,
168
+ ` tx ${explorerTxUrl(network, txHash)}`,
169
+ '',
170
+ 'Resolve with: nebula identity show',
171
+ ].join('\n'),
172
+ )
173
+ } catch (e) {
174
+ s.stop(`register failed: ${(e as Error).message.slice(0, 200)}`)
175
+ } finally {
176
+ await operator.close?.()
177
+ }
178
+ }
@@ -0,0 +1,40 @@
1
+ import { formatEther } from 'viem'
2
+
3
+ /** Mantle mainnet spot price used for USD estimates. Not authoritative, just a hint. */
4
+ const MNT_USD = 0.5
5
+
6
+ export type DeployTarget = 'local'
7
+
8
+ export interface CostBreakdown {
9
+ agentFloat: bigint
10
+ totalOperator: bigint
11
+ deployTarget: DeployTarget
12
+ }
13
+
14
+ export function estimateCosts(opts?: {
15
+ ledgerSizeOg?: number
16
+ withSubname?: boolean
17
+ deployTarget?: DeployTarget
18
+ agentFloatWei?: bigint
19
+ }): CostBreakdown {
20
+ // The only init cost is funding the agent EOA with a small gas float. There
21
+ // is no iNFT mint, no storage anchor, and no compute ledger.
22
+ const agentFloat = opts?.agentFloatWei ?? 100_000_000_000_000_000n // 0.1 MNT
23
+ return { agentFloat, totalOperator: agentFloat, deployTarget: opts?.deployTarget ?? 'local' }
24
+ }
25
+
26
+ export function formatUsd(valueWei: bigint): string {
27
+ const mnt = Number(formatEther(valueWei))
28
+ return `$${(mnt * MNT_USD).toFixed(2)}`
29
+ }
30
+
31
+ export function renderCostSummary(c: CostBreakdown): string {
32
+ const line = (label: string, wei: bigint): string =>
33
+ ` ${label.padEnd(32)}${formatEther(wei).padStart(8)} Mantle (${formatUsd(wei)})`
34
+ return [
35
+ ' operator spend (Mantle mainnet):',
36
+ line('agent infra float (gas)', c.agentFloat),
37
+ ` ${'─'.repeat(32)}${'─'.repeat(18)}`,
38
+ line('total operator spend', c.totalOperator),
39
+ ].join('\n')
40
+ }
@@ -0,0 +1,64 @@
1
+ import { cancel, isCancel, select } from '@clack/prompts'
2
+ import qrcode from 'qrcode-terminal'
3
+ import { type Address, type PublicClient, formatEther } from 'viem'
4
+
5
+ export interface FundingGateOpts {
6
+ publicClient: PublicClient
7
+ operatorAddress: Address
8
+ requiredOg: bigint
9
+ pollIntervalMs?: number
10
+ maxWaitMs?: number
11
+ }
12
+
13
+ export type FundingGateOutcome =
14
+ | { kind: 'funded'; balance: bigint }
15
+ | { kind: 'skip-ledger' }
16
+ | { kind: 'cancel' }
17
+
18
+ /**
19
+ * Show operator address as a QR and poll balance until it meets required
20
+ * threshold. User can cancel or choose to proceed with minimum-only (skip
21
+ * full compute ledger) at any point.
22
+ *
23
+ * Console prints the QR once; the polling loop updates a single line
24
+ * using `process.stdout.write` so the display doesn't scroll.
25
+ */
26
+ export async function fundingGate(opts: FundingGateOpts): Promise<FundingGateOutcome> {
27
+ const pollIntervalMs = opts.pollIntervalMs ?? 10_000
28
+ const maxWaitMs = opts.maxWaitMs ?? 30 * 60_000 // 30 minutes
29
+
30
+ console.log('')
31
+ console.log(` Send at least ${formatEther(opts.requiredOg)} Mantle to:`)
32
+ console.log(` ${opts.operatorAddress}`)
33
+ console.log('')
34
+ qrcode.generate(opts.operatorAddress, { small: true })
35
+ console.log('')
36
+
37
+ const start = Date.now()
38
+ while (Date.now() - start < maxWaitMs) {
39
+ const balance = await opts.publicClient.getBalance({ address: opts.operatorAddress })
40
+ if (balance >= opts.requiredOg) {
41
+ process.stdout.write('\r')
42
+ return { kind: 'funded', balance }
43
+ }
44
+ process.stdout.write(
45
+ `\r polling... current balance ${formatEther(balance)} Mantle (need ${formatEther(opts.requiredOg)}) `,
46
+ )
47
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
48
+ }
49
+
50
+ process.stdout.write('\n\n')
51
+ const choice = await select({
52
+ message: 'Balance still insufficient. What now?',
53
+ options: [
54
+ { value: 'skip' as const, label: 'Skip compute ledger for now (mint + subname only)' },
55
+ { value: 'cancel' as const, label: 'Cancel init' },
56
+ ],
57
+ initialValue: 'cancel',
58
+ })
59
+ if (isCancel(choice)) {
60
+ cancel('Aborted.')
61
+ return { kind: 'cancel' }
62
+ }
63
+ return choice === 'skip' ? { kind: 'skip-ledger' } : { kind: 'cancel' }
64
+ }
@@ -0,0 +1,25 @@
1
+ import type { NebulaNetwork } from 'nebula-ai-core'
2
+
3
+ export interface ModelPick {
4
+ provider: string
5
+ model: string | null
6
+ inputPricePerTokenWei: bigint
7
+ outputPricePerTokenWei: bigint
8
+ }
9
+
10
+ /**
11
+ * Nebula uses a fixed OpenAI-compatible model configured via env
12
+ * (`NEBULA_LLM_MODEL` / `NEBULA_LLM_BASE_URL` / `OPENAI_API_KEY`), so there's
13
+ * no live provider catalog to pick from. Return the configured default so
14
+ * `init` / `model` proceed without prompting.
15
+ */
16
+ export async function pickBrainModel(_opts: {
17
+ network: NebulaNetwork
18
+ }): Promise<ModelPick | null> {
19
+ return {
20
+ provider: 'openai-compatible',
21
+ model: process.env.NEBULA_LLM_MODEL ?? 'gpt-4o-mini',
22
+ inputPricePerTokenWei: 0n,
23
+ outputPricePerTokenWei: 0n,
24
+ }
25
+ }
@@ -0,0 +1,233 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { cancel, isCancel, note, password, select, text } from '@clack/prompts'
3
+ import {
4
+ KeychainOperatorSigner,
5
+ KeystoreFileOperatorSigner,
6
+ type NebulaNetwork,
7
+ type OperatorSigner,
8
+ type OperatorSourceHint,
9
+ type OperatorSourceKind,
10
+ RawPrivkeyOperatorSigner,
11
+ WalletConnectOperatorSigner,
12
+ } from 'nebula-ai-core'
13
+
14
+ interface PickerOptions {
15
+ network: NebulaNetwork
16
+ }
17
+
18
+ export interface OperatorPickResult {
19
+ signer: OperatorSigner
20
+ hint: OperatorSourceHint
21
+ }
22
+
23
+ /**
24
+ * Prompt the user for their operator wallet source and return both the
25
+ * connected `OperatorSigner` and the metadata needed to reconstruct it
26
+ * later (`OperatorSourceHint`). The hint is saved to `nebula.config.ts` by
27
+ * the wizard so subsequent commands (chat, topup, restore) can re-attach
28
+ * to the same source without re-prompting.
29
+ *
30
+ * Platform-aware: on macOS, all four sources are offered. On Linux/Windows
31
+ * the OS keychain option is hidden because libsecret/Credential-Manager
32
+ * support is post-MVP.
33
+ */
34
+ export async function pickOperatorSigner(opts: PickerOptions): Promise<OperatorPickResult | null> {
35
+ const isMac = process.platform === 'darwin'
36
+ const choices: { value: OperatorSourceKind; label: string; hint?: string }[] = [
37
+ {
38
+ value: 'walletconnect',
39
+ label: 'WalletConnect',
40
+ hint: 'scan QR with any WC-compatible mobile wallet',
41
+ },
42
+ ...(isMac
43
+ ? ([
44
+ {
45
+ value: 'keychain',
46
+ label: 'macOS Keychain',
47
+ hint: 'stored in login keychain',
48
+ },
49
+ ] as const)
50
+ : []),
51
+ {
52
+ value: 'keystore-file',
53
+ label: 'Keystore file',
54
+ hint: 'encrypted JSON, geth format',
55
+ },
56
+ {
57
+ value: 'raw-privkey',
58
+ label: 'Raw private key',
59
+ hint: 'stdin prompt, for CI/scripting',
60
+ },
61
+ ]
62
+ const source = (await select({
63
+ message: 'Connect your operator wallet (owns the iNFT)',
64
+ options: choices,
65
+ initialValue: choices[0]!.value,
66
+ })) as OperatorSourceKind | symbol
67
+ if (isCancel(source)) {
68
+ cancel('Aborted.')
69
+ return null
70
+ }
71
+
72
+ switch (source) {
73
+ case 'walletconnect':
74
+ return {
75
+ signer: new WalletConnectOperatorSigner({ networks: [opts.network] }),
76
+ hint: { source: 'walletconnect' },
77
+ }
78
+ case 'keychain': {
79
+ const service = await text({
80
+ message: 'Keychain service name',
81
+ placeholder: 'nebula.operator',
82
+ validate: v => {
83
+ if (!v || v.length === 0) return 'Required.'
84
+ if (!/^[a-zA-Z0-9._-]{1,128}$/.test(v))
85
+ return 'Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen (max 128).'
86
+ return undefined
87
+ },
88
+ })
89
+ if (isCancel(service)) {
90
+ cancel('Aborted.')
91
+ return null
92
+ }
93
+ const svc = service as string
94
+ return {
95
+ signer: new KeychainOperatorSigner(svc),
96
+ hint: { source: 'keychain', keychainService: svc },
97
+ }
98
+ }
99
+ case 'keystore-file': {
100
+ const path = await text({
101
+ message: 'Path to encrypted JSON keystore',
102
+ placeholder: '~/wallets/operator.json',
103
+ validate: v => {
104
+ if (!v) return 'Required.'
105
+ const expanded = v.replace(/^~/, process.env.HOME ?? '~')
106
+ if (!existsSync(expanded)) return `File not found: ${expanded}`
107
+ return undefined
108
+ },
109
+ })
110
+ if (isCancel(path)) {
111
+ cancel('Aborted.')
112
+ return null
113
+ }
114
+ const expanded = (path as string).replace(/^~/, process.env.HOME ?? '~')
115
+ const pass = await password({
116
+ message: 'Passphrase for the keystore',
117
+ validate: v => (v && v.length > 0 ? undefined : 'Required.'),
118
+ })
119
+ if (isCancel(pass)) {
120
+ cancel('Aborted.')
121
+ return null
122
+ }
123
+ return {
124
+ signer: new KeystoreFileOperatorSigner({ path: expanded, passphrase: pass as string }),
125
+ hint: { source: 'keystore-file', keystorePath: path as string },
126
+ }
127
+ }
128
+ case 'raw-privkey': {
129
+ if (process.env.NEBULA_OPERATOR_PRIVKEY) {
130
+ note('Using NEBULA_OPERATOR_PRIVKEY from env.', 'raw-privkey')
131
+ return {
132
+ signer: new RawPrivkeyOperatorSigner({
133
+ privkey: process.env.NEBULA_OPERATOR_PRIVKEY,
134
+ sourceLabel: 'env:NEBULA_OPERATOR_PRIVKEY',
135
+ }),
136
+ hint: { source: 'raw-privkey' },
137
+ }
138
+ }
139
+ const pk = await password({
140
+ message: 'Operator private key (hex, 0x prefix optional)',
141
+ validate: v => {
142
+ if (!v) return 'Required.'
143
+ const clean = v.trim().replace(/^0x/, '')
144
+ if (!/^[0-9a-fA-F]{64}$/.test(clean)) return 'Must be 32 bytes hex.'
145
+ return undefined
146
+ },
147
+ })
148
+ if (isCancel(pk)) {
149
+ cancel('Aborted.')
150
+ return null
151
+ }
152
+ return {
153
+ signer: new RawPrivkeyOperatorSigner({ privkey: pk as string, sourceLabel: 'stdin' }),
154
+ hint: { source: 'raw-privkey' },
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Reload an `OperatorSigner` from a previously persisted hint in
162
+ * `nebula.config.ts`. Used by chat / topup / restore / resume so the user
163
+ * doesn't re-pick a source every session — they only re-supply per-session
164
+ * secrets (passphrases / QR scans / env vars).
165
+ *
166
+ * Returns null when the hint is missing or unusable; the caller falls back
167
+ * to `pickOperatorSigner` for an interactive choice.
168
+ */
169
+ export async function loadOperatorFromHint(
170
+ hint: OperatorSourceHint,
171
+ network: NebulaNetwork,
172
+ ): Promise<OperatorSigner | null> {
173
+ switch (hint.source) {
174
+ case 'walletconnect':
175
+ return new WalletConnectOperatorSigner({ networks: [network] })
176
+ case 'keychain': {
177
+ if (!hint.keychainService) return null
178
+ return new KeychainOperatorSigner(hint.keychainService)
179
+ }
180
+ case 'keystore-file': {
181
+ if (!hint.keystorePath) return null
182
+ const expanded = hint.keystorePath.replace(/^~/, process.env.HOME ?? '~')
183
+ if (!existsSync(expanded)) {
184
+ note(`Operator keystore not found at ${expanded}; pick a new source.`, 'keystore missing')
185
+ return null
186
+ }
187
+ const pass = await password({
188
+ message: `Passphrase for operator keystore ${expanded}`,
189
+ validate: v => (v && v.length > 0 ? undefined : 'Required.'),
190
+ })
191
+ if (isCancel(pass)) return null
192
+ return new KeystoreFileOperatorSigner({
193
+ path: expanded,
194
+ passphrase: pass as string,
195
+ })
196
+ }
197
+ case 'raw-privkey': {
198
+ if (process.env.NEBULA_OPERATOR_PRIVKEY) {
199
+ return new RawPrivkeyOperatorSigner({
200
+ privkey: process.env.NEBULA_OPERATOR_PRIVKEY,
201
+ sourceLabel: 'env:NEBULA_OPERATOR_PRIVKEY',
202
+ })
203
+ }
204
+ const pk = await password({
205
+ message: 'Operator private key (hex, 0x prefix optional)',
206
+ validate: v => {
207
+ if (!v) return 'Required.'
208
+ const clean = v.trim().replace(/^0x/, '')
209
+ if (!/^[0-9a-fA-F]{64}$/.test(clean)) return 'Must be 32 bytes hex.'
210
+ return undefined
211
+ },
212
+ })
213
+ if (isCancel(pk)) return null
214
+ return new RawPrivkeyOperatorSigner({ privkey: pk as string, sourceLabel: 'stdin' })
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * High-level helper: load operator from config hint when available, fall
221
+ * back to interactive picker otherwise. Used by all post-init commands.
222
+ */
223
+ export async function loadOrPickOperatorSigner(opts: {
224
+ network: NebulaNetwork
225
+ hint?: OperatorSourceHint | null
226
+ }): Promise<OperatorSigner | null> {
227
+ if (opts.hint) {
228
+ const signer = await loadOperatorFromHint(opts.hint, opts.network)
229
+ if (signer) return signer
230
+ }
231
+ const picked = await pickOperatorSigner({ network: opts.network })
232
+ return picked?.signer ?? null
233
+ }