@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,198 @@
1
+ import type {
2
+ PermissionDecision,
3
+ PermissionMode,
4
+ PermissionRequest,
5
+ SlashCommand,
6
+ } from '@promus/core'
7
+ import { type JobEvent, isJobTerminalKind } from '@promus/plugin-comms'
8
+ import { createSignal } from 'solid-js'
9
+
10
+ export type TurnRole =
11
+ | 'user'
12
+ | 'assistant'
13
+ | 'system'
14
+ | 'tool-call'
15
+ | 'tool-result'
16
+ | 'inbox'
17
+ | 'market'
18
+ | 'inbox-tg'
19
+ | 'telegram-assistant'
20
+
21
+ export interface TurnRow {
22
+ id: string
23
+ role: TurnRole
24
+ text: string
25
+ // tool-call rows: tool name + formatted args (rendered as `name(args)`)
26
+ toolName?: string
27
+ args?: string
28
+ // tool-result rows: failure flag drives icon + color
29
+ failed?: boolean
30
+ // v0.21.2: drives the ↪ prefix so operators see the SAME logical fetch was
31
+ // escalated, not a fresh brain decision.
32
+ autoEscalated?: boolean
33
+ // True only for the first row in an "promus block" (assistant + tool-call rows
34
+ // that share the same speaker turn). Computed once at push time so the For
35
+ // loop renderer doesn't re-walk neighbors on every state mutation.
36
+ firstOfBlock?: boolean
37
+ }
38
+
39
+ export interface PendingApproval {
40
+ request: PermissionRequest
41
+ resolve: (decision: PermissionDecision) => void
42
+ }
43
+
44
+ interface CreateChatStateOpts {
45
+ initialSystem: string
46
+ identityLabel: string
47
+ approvalsMode: PermissionMode
48
+ // v0.24.4: true when the TUI talks to a local gateway daemon over a unix
49
+ // socket (`~/.promus/agents/<id>/gateway.sock`) instead of a remote Daytona
50
+ // sandbox endpoint. Drives statusbar copy (drops the "sandbox X" prefix on
51
+ // the system line) and hides the sandbox-billing balance segment (which is
52
+ // meaningless for local deploys — there is no billing reserve to surface).
53
+ // Defaults to false so existing call sites that don't pass it (i.e. nothing
54
+ // today, since both call sites set it explicitly) keep sandbox semantics.
55
+ isLocalGateway?: boolean
56
+ /** Native gas-token symbol for the agent's network ('ETH' / '0G'). */
57
+ currency?: string
58
+ }
59
+
60
+ export function createChatState(opts: CreateChatStateOpts) {
61
+ const [rows, setRows] = createSignal<TurnRow[]>([
62
+ { id: 'sys-0', role: 'system', text: opts.initialSystem },
63
+ ])
64
+ const [input, setInput] = createSignal('')
65
+ const [status, setStatus] = createSignal<'idle' | 'thinking' | 'error'>('idle')
66
+ const [usage, setUsage] = createSignal<{ total?: number; cached?: number } | null>(null)
67
+ const [pendingApproval, setPendingApproval] = createSignal<PendingApproval | null>(null)
68
+ const [approvalsMode, setApprovalsMode] = createSignal<PermissionMode>(opts.approvalsMode)
69
+
70
+ // 0G Compute ledger balance, in 0G. Refreshed at chat init and after each
71
+ // per-turn auto-sync. null = not yet fetched / fetch failed.
72
+ const [balance, setBalance] = createSignal<number | null>(null)
73
+ // Agent EOA balance, in 0G. Pays gas for chain writes (agent.message
74
+ // inbox.send, sync's updateSlots anchor). Typically starves before the
75
+ // compute ledger in long sessions (~0.001 0G/send at 4 gwei).
76
+ const [eoaBalance, setEoaBalance] = createSignal<number | null>(null)
77
+ // v0.22.0: 0G Sandbox billing reserve, in 0G. Sandbox-deployed agents only —
78
+ // local-mode TUI stays null and the statusline `<Show>` hides the segment.
79
+ // Auto-topup refills this when it dips below the configured threshold; the
80
+ // statusline mirror lets operators see the same balance without leaving TUI.
81
+ const [sandboxBalance, setSandboxBalance] = createSignal<number | null>(null)
82
+ // ms epoch when current turn started (status flipped to 'thinking'). The
83
+ // spinner row reads this and renders elapsed seconds. Cleared on idle.
84
+ const [turnStartedAt, setTurnStartedAt] = createSignal<number | null>(null)
85
+
86
+ // Phase 8: in-flight escrow jobs the agent is a party to (buyer or
87
+ // provider). Incremented on JobCreated, decremented once per terminal
88
+ // event per jobId. The contract emits both JobForceClosed AND JobSettled
89
+ // when force-closing a Done job (force-close routes through _settle), so
90
+ // we de-dup by jobId here to keep the counter honest.
91
+ const [activeJobCount, setActiveJobCount] = createSignal(0)
92
+ const terminatedJobs = new Set<string>()
93
+ const bumpActiveJobs = (e: JobEvent) => {
94
+ if (e.kind === 'created') {
95
+ setActiveJobCount(c => c + 1)
96
+ return
97
+ }
98
+ if (!isJobTerminalKind(e.kind)) return
99
+ const id = e.jobId.toString()
100
+ if (terminatedJobs.has(id)) return
101
+ terminatedJobs.add(id)
102
+ setActiveJobCount(c => Math.max(0, c - 1))
103
+ }
104
+
105
+ // Per-turn AbortController. Set when handleSubmit kicks off brain.infer;
106
+ // cleared (set to null) after the turn ends or is aborted. The keyboard
107
+ // handler reads it to wire Esc → abort.
108
+ const [activeAbort, setActiveAbort] = createSignal<AbortController | null>(null)
109
+
110
+ // v0.20.0: slash-command autocomplete popup state. `slashMatches` is the
111
+ // filtered list of commands matching the current input prefix; populated
112
+ // when input starts with `/`, cleared otherwise. `slashIndex` tracks the
113
+ // selected row inside `slashMatches`. Both reset to defaults on submit.
114
+ const [slashMatches, setSlashMatches] = createSignal<SlashCommand[]>([])
115
+ const [slashIndex, setSlashIndex] = createSignal(0)
116
+
117
+ // Status-change subscribers. Phase 12 telegram-dispatch hooks here so it
118
+ // can drain its queue when the brain returns to idle from a stdin turn.
119
+ type StatusListener = (next: 'idle' | 'thinking' | 'error') => void
120
+ const statusListeners = new Set<StatusListener>()
121
+ const onStatusChange = (cb: StatusListener): (() => void) => {
122
+ statusListeners.add(cb)
123
+ return () => statusListeners.delete(cb)
124
+ }
125
+
126
+ // Wrap status setter so the turn-start timestamp tracks status changes
127
+ // automatically. Every code path that flips to 'thinking' starts the
128
+ // clock; every flip to idle/error stops it. Removes the burden from
129
+ // call sites.
130
+ const setStatusTracked: typeof setStatus = next => {
131
+ const prev = status()
132
+ const result = setStatus(next)
133
+ const after = status()
134
+ if (prev !== 'thinking' && after === 'thinking') setTurnStartedAt(Date.now())
135
+ else if (prev === 'thinking' && after !== 'thinking') setTurnStartedAt(null)
136
+ if (prev !== after) {
137
+ for (const cb of statusListeners) {
138
+ try {
139
+ cb(after)
140
+ } catch {
141
+ // listener errors must not break status updates
142
+ }
143
+ }
144
+ }
145
+ return result
146
+ }
147
+
148
+ let idCounter = 1
149
+ const nextId = () => `row-${idCounter++}`
150
+
151
+ const pushRow = (row: Omit<TurnRow, 'id' | 'firstOfBlock'>) => {
152
+ setRows(prev => {
153
+ const last = prev[prev.length - 1] ?? null
154
+ const isAssistantBlock = row.role === 'assistant' || row.role === 'tool-call'
155
+ const continuesBlock =
156
+ last?.role === 'assistant' || last?.role === 'tool-call' || last?.role === 'tool-result'
157
+ const firstOfBlock = isAssistantBlock && !continuesBlock
158
+ return [...prev, { ...row, id: nextId(), firstOfBlock }]
159
+ })
160
+ }
161
+
162
+ return {
163
+ rows,
164
+ input,
165
+ status,
166
+ usage,
167
+ pendingApproval,
168
+ approvalsMode,
169
+ balance,
170
+ eoaBalance,
171
+ sandboxBalance,
172
+ turnStartedAt,
173
+ activeAbort,
174
+ activeJobCount,
175
+ slashMatches,
176
+ slashIndex,
177
+ setInput,
178
+ setStatus: setStatusTracked,
179
+ setUsage,
180
+ setPendingApproval,
181
+ setApprovalsMode,
182
+ setBalance,
183
+ setEoaBalance,
184
+ setSandboxBalance,
185
+ setTurnStartedAt,
186
+ setActiveAbort,
187
+ setSlashMatches,
188
+ setSlashIndex,
189
+ bumpActiveJobs,
190
+ pushRow,
191
+ onStatusChange,
192
+ identityLabel: opts.identityLabel,
193
+ isLocalGateway: opts.isLocalGateway ?? false,
194
+ currency: opts.currency ?? 'ETH',
195
+ }
196
+ }
197
+
198
+ export type ChatState = ReturnType<typeof createChatState>
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { resolveBootstrapMode } from './bootstrap-mode'
3
+
4
+ describe('resolveBootstrapMode', () => {
5
+ it('defaults to npm when no env is set', () => {
6
+ expect(resolveBootstrapMode({})).toBe('npm')
7
+ })
8
+
9
+ it('respects explicit PROMUS_BOOTSTRAP_MODE=git', () => {
10
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'git' })).toBe('git')
11
+ })
12
+
13
+ it('respects explicit PROMUS_BOOTSTRAP_MODE=npm', () => {
14
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'npm' })).toBe('npm')
15
+ })
16
+
17
+ it("auto-implies git when PROMUS_BOOTSTRAP_REF is set without explicit mode (preserves 'deploy main' workflow)", () => {
18
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_REF: 'main' })).toBe('git')
19
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_REF: 'abc1234' })).toBe('git')
20
+ })
21
+
22
+ it('lets explicit MODE win over REF (e.g. NPM=v0.21.20 alongside REF=ignored)', () => {
23
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'npm', PROMUS_BOOTSTRAP_REF: 'main' })).toBe(
24
+ 'npm',
25
+ )
26
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'git', PROMUS_BOOTSTRAP_REF: 'main' })).toBe(
27
+ 'git',
28
+ )
29
+ })
30
+
31
+ it('falls through to npm on garbage PROMUS_BOOTSTRAP_MODE (typos, uppercase, etc.)', () => {
32
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'GIT' })).toBe('npm')
33
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'Npm' })).toBe('npm')
34
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_MODE: 'docker' })).toBe('npm')
35
+ })
36
+
37
+ it('treats empty-string PROMUS_BOOTSTRAP_REF as unset (does not flip to git)', () => {
38
+ expect(resolveBootstrapMode({ PROMUS_BOOTSTRAP_REF: '' })).toBe('npm')
39
+ })
40
+ })
@@ -0,0 +1,25 @@
1
+ import type { BootstrapMode } from '@promus/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
+ * promus@<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. `PROMUS_BOOTSTRAP_MODE=git|npm` — explicit operator override, wins.
13
+ * 2. `PROMUS_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.PROMUS_BOOTSTRAP_MODE === 'git') return 'git'
22
+ if (env.PROMUS_BOOTSTRAP_MODE === 'npm') return 'npm'
23
+ if (env.PROMUS_BOOTSTRAP_REF) return 'git'
24
+ return 'npm'
25
+ }
@@ -0,0 +1,190 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Writable } from 'node:stream'
3
+ import {
4
+ BootstrapProgressBox,
5
+ __testing,
6
+ mapBootstrapMarkerToStage,
7
+ } from './bootstrap-progress-box'
8
+
9
+ class CapturingStream extends Writable {
10
+ chunks: string[] = []
11
+ isTTY: boolean
12
+ constructor(isTTY: boolean) {
13
+ super()
14
+ this.isTTY = isTTY
15
+ }
16
+ override _write(chunk: Buffer | string, _enc: string, cb: () => void): void {
17
+ this.chunks.push(chunk.toString())
18
+ cb()
19
+ }
20
+ combined(): string {
21
+ return this.chunks.join('')
22
+ }
23
+ }
24
+
25
+ describe('mapBootstrapMarkerToStage', () => {
26
+ test('maps every gateway bootstrap.ts marker', () => {
27
+ expect(mapBootstrapMarkerToStage('updating package index')).toBe('apt-update')
28
+ expect(
29
+ mapBootstrapMarkerToStage('installing system deps (build-essential, curl, git, xvfb)'),
30
+ ).toBe('system-deps')
31
+ expect(mapBootstrapMarkerToStage('installing bun runtime')).toBe('bun-install')
32
+ expect(mapBootstrapMarkerToStage('installing promus (0.24.7)')).toBe('promus-install')
33
+ expect(mapBootstrapMarkerToStage('installing promus (git main)')).toBe('promus-install')
34
+ expect(mapBootstrapMarkerToStage('installing chrome for browser tools')).toBe('browser-deps')
35
+ expect(mapBootstrapMarkerToStage('starting harness daemon')).toBe('harness-spawn')
36
+ expect(mapBootstrapMarkerToStage('harness ready')).toBe('harness-spawn')
37
+ })
38
+
39
+ test('returns null for unknown markers', () => {
40
+ expect(mapBootstrapMarkerToStage('something else entirely')).toBeNull()
41
+ expect(mapBootstrapMarkerToStage('')).toBeNull()
42
+ })
43
+ })
44
+
45
+ describe('BootstrapProgressBox TTY mode', () => {
46
+ test('start renders the initial frame with all stages pending', () => {
47
+ const out = new CapturingStream(true)
48
+ const box = new BootstrapProgressBox({ out })
49
+ box.start()
50
+ const combined = out.combined()
51
+ expect(combined).toContain('bootstrap progress')
52
+ expect(combined).toContain('launchScript uploaded to Daytona')
53
+ expect(combined).toContain('apt update')
54
+ expect(combined).toContain('/healthz Ready')
55
+ expect(combined).toContain('╰')
56
+ })
57
+
58
+ test('markStage running uses spinner glyph; done uses checkmark', () => {
59
+ const out = new CapturingStream(true)
60
+ const box = new BootstrapProgressBox({ out })
61
+ box.start()
62
+ box.markStage('launch-upload', 'running')
63
+ const afterRunning = out.combined()
64
+ const runningGlyph = __testing.SPINNER_FRAMES.find(g => afterRunning.includes(g))
65
+ expect(runningGlyph).toBeDefined()
66
+
67
+ box.markStage('launch-upload', 'done')
68
+ expect(out.combined()).toContain('✓')
69
+ })
70
+
71
+ test('markStage running auto-completes prior pending stages (handles skipped bun-install)', () => {
72
+ const out = new CapturingStream(true)
73
+ const box = new BootstrapProgressBox({ out })
74
+ box.start()
75
+ box.markStage('apt-update', 'running')
76
+ box.markStage('system-deps', 'running')
77
+ // Skip bun-install — bun already installed
78
+ box.markStage('promus-install', 'running')
79
+ const checkmarks = (out.combined().match(/✓/g) ?? []).length
80
+ // Stages auto-completed: launch-upload (skipped), apt-update, system-deps, bun-install (skipped)
81
+ expect(checkmarks).toBeGreaterThanOrEqual(4)
82
+ })
83
+
84
+ test('stop finalizes any running stage as done and appends a newline', () => {
85
+ const out = new CapturingStream(true)
86
+ const box = new BootstrapProgressBox({ out })
87
+ box.start()
88
+ box.markStage('healthz-ready', 'running')
89
+ box.stop()
90
+ const combined = out.combined()
91
+ const checkmarks = (combined.match(/✓/g) ?? []).length
92
+ expect(checkmarks).toBeGreaterThanOrEqual(1)
93
+ expect(combined.endsWith('\n')).toBe(true)
94
+ })
95
+
96
+ test('fail marks running stage as failed (✗) and leaves later pending stages alone', () => {
97
+ const out = new CapturingStream(true)
98
+ const box = new BootstrapProgressBox({ out })
99
+ box.start()
100
+ box.markStage('promus-install', 'running')
101
+ box.fail()
102
+ expect(out.combined()).toContain('✗')
103
+ })
104
+
105
+ test('uses ANSI cursor-up + clear-to-end between renders', () => {
106
+ const out = new CapturingStream(true)
107
+ const box = new BootstrapProgressBox({ out })
108
+ box.start()
109
+ box.markStage('apt-update', 'running')
110
+ const combined = out.combined()
111
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: validates emitted ANSI escapes
112
+ expect(combined).toMatch(/\x1b\[\d+A/)
113
+ expect(combined).toContain('\x1b[0J')
114
+ })
115
+
116
+ test('honors custom label override', () => {
117
+ const out = new CapturingStream(true)
118
+ const box = new BootstrapProgressBox({
119
+ out,
120
+ labels: { 'promus-install': 'promus 0.24.7 installed' },
121
+ })
122
+ box.start()
123
+ expect(out.combined()).toContain('promus 0.24.7 installed')
124
+ })
125
+
126
+ test('row layout pads to constant width regardless of label length', () => {
127
+ const out = new CapturingStream(true)
128
+ const box = new BootstrapProgressBox({ out })
129
+ box.start()
130
+ const lines = out
131
+ .combined()
132
+ .split('\n')
133
+ .filter(l => l.startsWith('│'))
134
+ const widths = lines.map(l => stripAnsi(l).length)
135
+ const unique = new Set(widths)
136
+ expect(unique.size).toBe(1)
137
+ })
138
+ })
139
+
140
+ describe('BootstrapProgressBox non-TTY fallback', () => {
141
+ test('emits per-transition lines without ANSI', () => {
142
+ const out = new CapturingStream(false)
143
+ const box = new BootstrapProgressBox({ out })
144
+ box.start()
145
+ box.markStage('apt-update', 'running')
146
+ box.markStage('apt-update', 'done')
147
+ const combined = out.combined()
148
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: asserts absence of ANSI escapes
149
+ expect(combined).not.toMatch(/\x1b\[/)
150
+ expect(combined).toContain('[..]')
151
+ expect(combined).toContain('[ok]')
152
+ expect(combined).toContain('apt update')
153
+ })
154
+
155
+ test('does not re-print the same status twice in non-TTY mode', () => {
156
+ const out = new CapturingStream(false)
157
+ const box = new BootstrapProgressBox({ out })
158
+ box.start()
159
+ box.markStage('apt-update', 'done')
160
+ box.tick()
161
+ box.tick()
162
+ const okLines = (out.combined().match(/\[ok\] /g) ?? []).length
163
+ expect(okLines).toBe(1)
164
+ })
165
+ })
166
+
167
+ describe('formatTime', () => {
168
+ test('00:00 for zero', () => {
169
+ expect(__testing.formatTime(0)).toBe('00:00')
170
+ })
171
+ test('zero pads single-digit seconds and minutes', () => {
172
+ expect(__testing.formatTime(7)).toBe('00:07')
173
+ expect(__testing.formatTime(65)).toBe('01:05')
174
+ expect(__testing.formatTime(601)).toBe('10:01')
175
+ })
176
+ })
177
+
178
+ describe('truncate', () => {
179
+ test('returns as-is when within limit', () => {
180
+ expect(__testing.truncate('hello', 10)).toBe('hello')
181
+ })
182
+ test('truncates with ellipsis when over limit', () => {
183
+ expect(__testing.truncate('a very long label that overflows', 12)).toBe('a very long…')
184
+ })
185
+ })
186
+
187
+ function stripAnsi(s: string): string {
188
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escapes
189
+ return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
190
+ }