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,32 @@
1
+ import type { PermissionRequest } from 'nebula-ai-core'
2
+ import { shortAddr } from '../util/format'
3
+
4
+ /**
5
+ * Body line for the approval modal. Friendly text for value-moving onchain
6
+ * kinds; falls back to command/path for shell.run / fs.write / code.execute.
7
+ *
8
+ * Why the `'→'` sniff in chain.send: chain.wrap and chain.unwrap reuse
9
+ * `chain.send` as their permission kind but encode the operation in `token`
10
+ * (`MNT→WMNT` / `WMNT→MNT`) and have no recipient to display.
11
+ */
12
+ export function summarizeApprovalSubject(req: PermissionRequest): string {
13
+ const amt = req.amount ?? ''
14
+ const tok = req.token ?? ''
15
+ switch (req.kind) {
16
+ case 'chain.send': {
17
+ if (tok.includes('→')) return `${amt} ${tok}`.trim()
18
+ const tokenLabel = tok || 'MNT'
19
+ return `send ${amt} ${tokenLabel} to ${shortAddr(req.recipient)}`
20
+ }
21
+ case 'chain.swap':
22
+ if (!amt && !tok) return 'swap'
23
+ return `swap ${amt} ${tok}`.trim()
24
+ case 'chain.write': {
25
+ const valuePart = amt ? ` (value: ${amt})` : ''
26
+ const onPart = req.recipient ? ` on ${shortAddr(req.recipient)}` : ''
27
+ return `${req.command ?? '?'}${valuePart}${onPart}`
28
+ }
29
+ default:
30
+ return req.command ?? req.path ?? '(unspecified)'
31
+ }
32
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Lightweight markdown parser for the assistant chat rows. Pure logic only,
3
+ * no JSX, so tests can import without dragging in the JSX runtime (CI's bun
4
+ * defaults to react-jsx and fails to resolve `react/jsx-dev-runtime` when
5
+ * a .tsx file is imported by a test).
6
+ *
7
+ * Subset the brain actually emits: `**bold**`, `*italic*`, `` `code` ``,
8
+ * `# headings`, `- bullet lists`, `1. numbered lists`, fenced code blocks,
9
+ * GFM tables (`| col | col |` + `|---|---|` separator).
10
+ */
11
+
12
+ export interface MdSegment {
13
+ text: string
14
+ fg?: string
15
+ bold?: boolean
16
+ italic?: boolean
17
+ }
18
+
19
+ export const MD_COLORS = {
20
+ text: '#e5e7eb',
21
+ code: '#fda4af',
22
+ heading: '#fbbf24',
23
+ bullet: '#94a3b8',
24
+ codeBlock: '#f9a8d4',
25
+ tableBorder: '#6b7280',
26
+ tableHeader: '#fbbf24',
27
+ }
28
+
29
+ /**
30
+ * Parse a single line's inline markup (`**bold**`, `*italic*`, `` `code` ``)
31
+ * into a flat list of segments. Caller handles the line-level structure.
32
+ */
33
+ function parseInline(line: string, baseFg: string = MD_COLORS.text): MdSegment[] {
34
+ const out: MdSegment[] = []
35
+ let i = 0
36
+ let plain = ''
37
+ const flushPlain = () => {
38
+ if (plain) {
39
+ out.push({ text: plain, fg: baseFg })
40
+ plain = ''
41
+ }
42
+ }
43
+ while (i < line.length) {
44
+ if (line[i] === '`') {
45
+ const end = line.indexOf('`', i + 1)
46
+ if (end > i) {
47
+ flushPlain()
48
+ out.push({ text: line.slice(i + 1, end), fg: MD_COLORS.code })
49
+ i = end + 1
50
+ continue
51
+ }
52
+ }
53
+ if (line[i] === '*' && line[i + 1] === '*') {
54
+ const end = line.indexOf('**', i + 2)
55
+ if (end > i + 2) {
56
+ flushPlain()
57
+ out.push({ text: line.slice(i + 2, end), fg: baseFg, bold: true })
58
+ i = end + 2
59
+ continue
60
+ }
61
+ }
62
+ if (line[i] === '*' && line[i + 1] !== '*' && line[i + 1] !== ' ') {
63
+ const end = line.indexOf('*', i + 1)
64
+ if (end > i + 1 && line[end - 1] !== ' ' && line[end + 1] !== '*') {
65
+ flushPlain()
66
+ out.push({ text: line.slice(i + 1, end), fg: baseFg, italic: true })
67
+ i = end + 1
68
+ continue
69
+ }
70
+ }
71
+ plain += line[i]
72
+ i++
73
+ }
74
+ flushPlain()
75
+ return out
76
+ }
77
+
78
+ // GFM table separator row: `|---|---|` (optionally with alignment colons).
79
+ // Allows single-column tables (`|---|`), multi-column (`|---|---|`), and
80
+ // missing leading/trailing pipes (`---|---`).
81
+ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
82
+
83
+ function parseTableRow(line: string): string[] {
84
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
85
+ return trimmed.split('|').map(c => c.trim())
86
+ }
87
+
88
+ /**
89
+ * Detect a GFM table starting at `lines[startIdx]`. Returns the parsed rows
90
+ * (header included as row 0) plus the index AFTER the last data row, or null
91
+ * if no table block matches.
92
+ */
93
+ function detectTable(lines: string[], startIdx: number): { rows: string[][]; end: number } | null {
94
+ const header = lines[startIdx]
95
+ if (header === undefined) return null
96
+ if (!/^\s*\|.+\|?\s*$/.test(header)) return null
97
+ const sep = lines[startIdx + 1]
98
+ if (!sep || !TABLE_SEPARATOR_RE.test(sep)) return null
99
+
100
+ const rows: string[][] = [parseTableRow(header)]
101
+ let i = startIdx + 2
102
+ while (i < lines.length) {
103
+ const ln = lines[i]
104
+ if (ln === undefined || !/^\s*\|.+\|?\s*$/.test(ln)) break
105
+ rows.push(parseTableRow(ln))
106
+ i++
107
+ }
108
+ return { rows, end: i }
109
+ }
110
+
111
+ /**
112
+ * Render a parsed table as flat segments. Uses box-drawing characters for the
113
+ * separator under the header row; columns are padded to the widest cell. First
114
+ * row is rendered bold + heading color so it stands out.
115
+ */
116
+ function renderTable(rows: string[][], out: MdSegment[], pushNewline: () => void): void {
117
+ if (rows.length === 0) return
118
+ const colCount = Math.max(...rows.map(r => r.length))
119
+ const widths = new Array(colCount).fill(0) as number[]
120
+ for (const row of rows) {
121
+ for (let c = 0; c < row.length; c++) {
122
+ widths[c] = Math.max(widths[c]!, row[c]!.length)
123
+ }
124
+ }
125
+ for (let r = 0; r < rows.length; r++) {
126
+ pushNewline()
127
+ const row = rows[r]!
128
+ const cells: string[] = []
129
+ for (let c = 0; c < colCount; c++) {
130
+ const cell = (row[c] ?? '').padEnd(widths[c]!, ' ')
131
+ cells.push(cell)
132
+ }
133
+ const lineText = `│ ${cells.join(' │ ')} │`
134
+ out.push({
135
+ text: lineText,
136
+ fg: r === 0 ? MD_COLORS.tableHeader : MD_COLORS.text,
137
+ bold: r === 0,
138
+ })
139
+ if (r === 0) {
140
+ pushNewline()
141
+ const sep = `├${widths.map(w => '─'.repeat(w + 2)).join('┼')}┤`
142
+ out.push({ text: sep, fg: MD_COLORS.tableBorder })
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Parse the full text into a flat list of segments separated by newlines.
149
+ * Block-level structure is encoded as styled prefixes in the segments
150
+ * (heading -> bold colored line; bullet -> "• " + content; table -> aligned
151
+ * cells with box-drawing separator).
152
+ */
153
+ export function parseMarkdown(text: string): MdSegment[] {
154
+ if (!text) return []
155
+ const out: MdSegment[] = []
156
+ const lines = text.split('\n')
157
+ let inFence = false
158
+ let firstLine = true
159
+
160
+ const pushNewline = () => {
161
+ if (!firstLine) out.push({ text: '\n', fg: MD_COLORS.text })
162
+ firstLine = false
163
+ }
164
+
165
+ let i = 0
166
+ while (i < lines.length) {
167
+ const rawLine = lines[i]!
168
+ if (rawLine.trim().startsWith('```')) {
169
+ inFence = !inFence
170
+ i++
171
+ continue
172
+ }
173
+ if (inFence) {
174
+ pushNewline()
175
+ out.push({ text: rawLine, fg: MD_COLORS.codeBlock })
176
+ i++
177
+ continue
178
+ }
179
+ const headingMatch = rawLine.match(/^(#{1,6})\s+(.*)$/)
180
+ if (headingMatch) {
181
+ pushNewline()
182
+ const inner = parseInline(headingMatch[2]!, MD_COLORS.heading)
183
+ for (const seg of inner) {
184
+ out.push({ ...seg, fg: seg.fg ?? MD_COLORS.heading, bold: true })
185
+ }
186
+ i++
187
+ continue
188
+ }
189
+ const table = detectTable(lines, i)
190
+ if (table) {
191
+ renderTable(table.rows, out, pushNewline)
192
+ i = table.end
193
+ continue
194
+ }
195
+ const bulletMatch = rawLine.match(/^(\s*)([-*])\s+(.*)$/)
196
+ if (bulletMatch) {
197
+ pushNewline()
198
+ out.push({ text: `${bulletMatch[1]}• `, fg: MD_COLORS.bullet })
199
+ out.push(...parseInline(bulletMatch[3]!))
200
+ i++
201
+ continue
202
+ }
203
+ const numberedMatch = rawLine.match(/^(\s*)(\d+)\.\s+(.*)$/)
204
+ if (numberedMatch) {
205
+ pushNewline()
206
+ out.push({
207
+ text: `${numberedMatch[1]}${numberedMatch[2]}. `,
208
+ fg: MD_COLORS.bullet,
209
+ })
210
+ out.push(...parseInline(numberedMatch[3]!))
211
+ i++
212
+ continue
213
+ }
214
+ pushNewline()
215
+ out.push(...parseInline(rawLine))
216
+ i++
217
+ }
218
+ return out
219
+ }
@@ -0,0 +1,37 @@
1
+ import { For } from 'solid-js'
2
+ import { parseMarkdown } from './markdown-parse'
3
+
4
+ export {
5
+ parseMarkdown,
6
+ MD_COLORS,
7
+ type MdSegment,
8
+ } from './markdown-parse'
9
+
10
+ /**
11
+ * Render parsed markdown segments as opentui spans inside an existing
12
+ * `<text>` block. Caller owns the wrapping `<text>` (so wrapMode + flexGrow
13
+ * stay configurable).
14
+ *
15
+ * Why custom rather than opentui's built-in `<markdown>`: nebula already
16
+ * renders assistant text inside a row that has a fixed-width prefix
17
+ * gutter; switching to `<markdown>` would break the indent and gutter
18
+ * alignment because it owns its own layout. A custom renderer that emits
19
+ * spans keeps the existing AssistantTextRow flow intact.
20
+ */
21
+ export function MarkdownSegments(props: { text: string }) {
22
+ const segments = () => parseMarkdown(props.text)
23
+ return (
24
+ <For each={segments()}>
25
+ {seg => {
26
+ // opentui's SpanProps type omits fg/bold/italic but the runtime
27
+ // accepts them. Cast through an object spread to bypass the check.
28
+ const styles = {
29
+ ...(seg.fg ? { fg: seg.fg } : {}),
30
+ ...(seg.bold ? { bold: true } : {}),
31
+ ...(seg.italic ? { italic: true } : {}),
32
+ } as Record<string, unknown>
33
+ return <span {...styles}>{seg.text}</span>
34
+ }}
35
+ </For>
36
+ )
37
+ }
@@ -0,0 +1,181 @@
1
+ import type {
2
+ PermissionDecision,
3
+ PermissionMode,
4
+ PermissionRequest,
5
+ SlashCommand,
6
+ } from 'nebula-ai-core'
7
+ import { createSignal } from 'solid-js'
8
+
9
+ export type TurnRole =
10
+ | 'user'
11
+ | 'assistant'
12
+ | 'system'
13
+ | 'tool-call'
14
+ | 'tool-result'
15
+ | 'inbox'
16
+ | 'market'
17
+ | 'inbox-tg'
18
+ | 'telegram-assistant'
19
+
20
+ export interface TurnRow {
21
+ id: string
22
+ role: TurnRole
23
+ text: string
24
+ // tool-call rows: tool name + formatted args (rendered as `name(args)`)
25
+ toolName?: string
26
+ args?: string
27
+ // tool-result rows: failure flag drives icon + color
28
+ failed?: boolean
29
+ // v0.21.2: drives the ↪ prefix so operators see the SAME logical fetch was
30
+ // escalated, not a fresh brain decision.
31
+ autoEscalated?: boolean
32
+ // True only for the first row in an "nebula block" (assistant + tool-call rows
33
+ // that share the same speaker turn). Computed once at push time so the For
34
+ // loop renderer doesn't re-walk neighbors on every state mutation.
35
+ firstOfBlock?: boolean
36
+ }
37
+
38
+ export interface PendingApproval {
39
+ request: PermissionRequest
40
+ resolve: (decision: PermissionDecision) => void
41
+ }
42
+
43
+ interface CreateChatStateOpts {
44
+ initialSystem: string
45
+ identityLabel: string
46
+ approvalsMode: PermissionMode
47
+ // v0.24.4: true when the TUI talks to a local gateway daemon over a unix
48
+ // socket (`~/.nebula/agents/<id>/gateway.sock`) instead of a remote Daytona
49
+ // sandbox endpoint. Drives statusbar copy (drops the "sandbox X" prefix on
50
+ // the system line) and hides the sandbox-billing balance segment (which is
51
+ // meaningless for local deploys — there is no billing reserve to surface).
52
+ // Defaults to false so existing call sites that don't pass it (i.e. nothing
53
+ // today, since both call sites set it explicitly) keep sandbox semantics.
54
+ isLocalGateway?: boolean
55
+ }
56
+
57
+ export function createChatState(opts: CreateChatStateOpts) {
58
+ const [rows, setRows] = createSignal<TurnRow[]>([
59
+ { id: 'sys-0', role: 'system', text: opts.initialSystem },
60
+ ])
61
+ const [input, setInput] = createSignal('')
62
+ const [status, setStatus] = createSignal<'idle' | 'thinking' | 'error'>('idle')
63
+ const [usage, setUsage] = createSignal<{ total?: number; cached?: number } | null>(null)
64
+ const [pendingApproval, setPendingApproval] = createSignal<PendingApproval | null>(null)
65
+ const [approvalsMode, setApprovalsMode] = createSignal<PermissionMode>(opts.approvalsMode)
66
+
67
+ // Mantle Compute ledger balance, in Mantle. Refreshed at chat init and after each
68
+ // per-turn auto-sync. null = not yet fetched / fetch failed.
69
+ const [balance, setBalance] = createSignal<number | null>(null)
70
+ // Agent EOA balance, in Mantle. Pays gas for chain writes (agent.message
71
+ // inbox.send, sync's updateSlots anchor). Typically starves before the
72
+ // compute ledger in long sessions (~0.001 Mantle/send at 4 gwei).
73
+ const [eoaBalance, setEoaBalance] = createSignal<number | null>(null)
74
+ // v0.22.0: Mantle Sandbox billing reserve, in Mantle. Sandbox-deployed agents only —
75
+ // local-mode TUI stays null and the statusline `<Show>` hides the segment.
76
+ // Auto-topup refills this when it dips below the configured threshold; the
77
+ // statusline mirror lets operators see the same balance without leaving TUI.
78
+ const [sandboxBalance, setSandboxBalance] = createSignal<number | null>(null)
79
+ // ms epoch when current turn started (status flipped to 'thinking'). The
80
+ // spinner row reads this and renders elapsed seconds. Cleared on idle.
81
+ const [turnStartedAt, setTurnStartedAt] = createSignal<number | null>(null)
82
+
83
+ // In-flight escrow job count surfaced in the statusbar. The marketplace
84
+ // wiring that populated this lived in the (removed) comms plugin; the signal
85
+ // is retained so the statusbar segment and the sandbox 'market' rows keep
86
+ // their type contract.
87
+ const [activeJobCount, setActiveJobCount] = createSignal(0)
88
+ void setActiveJobCount
89
+
90
+ // Per-turn AbortController. Set when handleSubmit kicks off brain.infer;
91
+ // cleared (set to null) after the turn ends or is aborted. The keyboard
92
+ // handler reads it to wire Esc → abort.
93
+ const [activeAbort, setActiveAbort] = createSignal<AbortController | null>(null)
94
+
95
+ // v0.20.0: slash-command autocomplete popup state. `slashMatches` is the
96
+ // filtered list of commands matching the current input prefix; populated
97
+ // when input starts with `/`, cleared otherwise. `slashIndex` tracks the
98
+ // selected row inside `slashMatches`. Both reset to defaults on submit.
99
+ const [slashMatches, setSlashMatches] = createSignal<SlashCommand[]>([])
100
+ const [slashIndex, setSlashIndex] = createSignal(0)
101
+
102
+ // Status-change subscribers. Phase 12 telegram-dispatch hooks here so it
103
+ // can drain its queue when the brain returns to idle from a stdin turn.
104
+ type StatusListener = (next: 'idle' | 'thinking' | 'error') => void
105
+ const statusListeners = new Set<StatusListener>()
106
+ const onStatusChange = (cb: StatusListener): (() => void) => {
107
+ statusListeners.add(cb)
108
+ return () => statusListeners.delete(cb)
109
+ }
110
+
111
+ // Wrap status setter so the turn-start timestamp tracks status changes
112
+ // automatically. Every code path that flips to 'thinking' starts the
113
+ // clock; every flip to idle/error stops it. Removes the burden from
114
+ // call sites.
115
+ const setStatusTracked: typeof setStatus = next => {
116
+ const prev = status()
117
+ const result = setStatus(next)
118
+ const after = status()
119
+ if (prev !== 'thinking' && after === 'thinking') setTurnStartedAt(Date.now())
120
+ else if (prev === 'thinking' && after !== 'thinking') setTurnStartedAt(null)
121
+ if (prev !== after) {
122
+ for (const cb of statusListeners) {
123
+ try {
124
+ cb(after)
125
+ } catch {
126
+ // listener errors must not break status updates
127
+ }
128
+ }
129
+ }
130
+ return result
131
+ }
132
+
133
+ let idCounter = 1
134
+ const nextId = () => `row-${idCounter++}`
135
+
136
+ const pushRow = (row: Omit<TurnRow, 'id' | 'firstOfBlock'>) => {
137
+ setRows(prev => {
138
+ const last = prev[prev.length - 1] ?? null
139
+ const isAssistantBlock = row.role === 'assistant' || row.role === 'tool-call'
140
+ const continuesBlock =
141
+ last?.role === 'assistant' || last?.role === 'tool-call' || last?.role === 'tool-result'
142
+ const firstOfBlock = isAssistantBlock && !continuesBlock
143
+ return [...prev, { ...row, id: nextId(), firstOfBlock }]
144
+ })
145
+ }
146
+
147
+ return {
148
+ rows,
149
+ input,
150
+ status,
151
+ usage,
152
+ pendingApproval,
153
+ approvalsMode,
154
+ balance,
155
+ eoaBalance,
156
+ sandboxBalance,
157
+ turnStartedAt,
158
+ activeAbort,
159
+ activeJobCount,
160
+ slashMatches,
161
+ slashIndex,
162
+ setInput,
163
+ setStatus: setStatusTracked,
164
+ setUsage,
165
+ setPendingApproval,
166
+ setApprovalsMode,
167
+ setBalance,
168
+ setEoaBalance,
169
+ setSandboxBalance,
170
+ setTurnStartedAt,
171
+ setActiveAbort,
172
+ setSlashMatches,
173
+ setSlashIndex,
174
+ pushRow,
175
+ onStatusChange,
176
+ identityLabel: opts.identityLabel,
177
+ isLocalGateway: opts.isLocalGateway ?? false,
178
+ }
179
+ }
180
+
181
+ export type ChatState = ReturnType<typeof createChatState>
@@ -0,0 +1,25 @@
1
+ import type { BootstrapMode } from 'nebula-ai-gateway'
2
+
3
+ /**
4
+ * Resolve the sandbox bootstrap mode from operator env.
5
+ *
6
+ * Default is `'npm'` since v0.21.20 (~10x faster cold start: `bun add -g
7
+ * nebula-ai-cli@<ver>` finishes in ~30-60s vs ~5-8min for `git clone +
8
+ * bun install`). The npm path was shipped in v0.21.15 and lived as opt-in
9
+ * for several releases before this flip.
10
+ *
11
+ * Resolution order:
12
+ * 1. `NEBULA_BOOTSTRAP_MODE=git|npm` — explicit operator override, wins.
13
+ * 2. `NEBULA_BOOTSTRAP_REF` set without explicit mode → 'git'. The REF env
14
+ * is a git-mode concept (branch tip / commit SHA); auto-implying git
15
+ * preserves the existing "deploy main", "deploy <sha>" dev workflows.
16
+ * 3. Otherwise → 'npm'.
17
+ *
18
+ * Callers pass `opts.mode` directly to bypass this resolver entirely.
19
+ */
20
+ export function resolveBootstrapMode(env: NodeJS.ProcessEnv = process.env): BootstrapMode {
21
+ if (env.NEBULA_BOOTSTRAP_MODE === 'git') return 'git'
22
+ if (env.NEBULA_BOOTSTRAP_MODE === 'npm') return 'npm'
23
+ if (env.NEBULA_BOOTSTRAP_REF) return 'git'
24
+ return 'npm'
25
+ }