@hoox-sh/hoox-tui 0.2.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 (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,1662 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * CliBridge — Spawns the hoox CLI binary and returns structured results.
8
+ *
9
+ * Provides convenience methods for all CLI operations used by TUI views,
10
+ * with configurable timeout, abort support, and stderr streaming.
11
+ *
12
+ * Every `CliResult` now carries `command` (the exact command string) and
13
+ * `errorType` (a `CliErrorType` classification) so the status bar can
14
+ * surface actionable diagnostics instead of a generic OFFLINE message.
15
+ *
16
+ * Global error sink: callers (e.g. the app root) can register an
17
+ * `onError` listener once via `cliBridge.onError(fn)` and receive a
18
+ * structured `CliErrorDetails` payload for **every** failed command —
19
+ * not just the ones the caller explicitly invoked. This is the wiring
20
+ * point that lets the status bar surface the real diagnostic context
21
+ * for any CLI failure, regardless of which view triggered it.
22
+ */
23
+ import * as path from "path";
24
+ import { getHooxRepoPath, resolveHooxRuntimeRoot } from "@hoox-sh/hoox-shared";
25
+ import type { CliResult, CliErrorType, CliErrorDetails } from "../../types";
26
+ import type {
27
+ ExecOptions,
28
+ KillSwitchAction,
29
+ KillSwitchStatus,
30
+ QueueDepthStatus,
31
+ QueueDepth,
32
+ DbQueryResult,
33
+ SecretMetadata,
34
+ SecretsSnapshot,
35
+ KvKey,
36
+ KvKeySnapshot,
37
+ ModelHealth,
38
+ AgentHealthResult,
39
+ PyneHealthResult,
40
+ } from "./types";
41
+ import { validateReadOnlySql } from "./standalone";
42
+ import { tuiDevLog, redactSecretsInText } from "../dev-log";
43
+
44
+ /** Cap stderr/stdout capture per command to keep the store lightweight. */
45
+ const MAX_OUTPUT_CHARS = 4096;
46
+
47
+ /** After SIGTERM on timeout/abort, escalate to SIGKILL if still alive. */
48
+ const KILL_GRACE_MS = 1_500;
49
+
50
+ /** Default max depth for visualization (clamps the fill-bar). */
51
+ const DEFAULT_QUEUE_MAX = 1000;
52
+ /** Heuristic scale: assume each producer is responsible for ~100 msgs. */
53
+ const DEPTH_PER_PRODUCER = 100;
54
+ /** Status thresholds (see {@link QueueDepthStatus} for semantics). */
55
+ const HEALTHY_THRESHOLD = 100;
56
+ const CRITICAL_THRESHOLD = 500;
57
+
58
+ /**
59
+ * Best-effort JSON parse for CLI stdout that may include leading noise
60
+ * (wrangler banners, progress lines). Never throws.
61
+ *
62
+ * Exported for unit tests.
63
+ */
64
+ export function tryParseJsonLoose(stdout: string): unknown | null {
65
+ const cleaned = stdout.trim();
66
+ if (!cleaned) return null;
67
+
68
+ try {
69
+ return JSON.parse(cleaned);
70
+ } catch {
71
+ /* fall through — try noise-tolerant strategies */
72
+ }
73
+
74
+ // Drop leading non-JSON lines (progress / ANSI stripped banners)
75
+ const lines = cleaned.split("\n");
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const candidate = lines.slice(i).join("\n").trim();
78
+ if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
79
+ try {
80
+ return JSON.parse(candidate);
81
+ } catch {
82
+ /* try next start line */
83
+ }
84
+ }
85
+
86
+ // Bracket-balanced extract from first `{` or `[`
87
+ const startObj = cleaned.indexOf("{");
88
+ const startArr = cleaned.indexOf("[");
89
+ const start =
90
+ startObj === -1
91
+ ? startArr
92
+ : startArr === -1
93
+ ? startObj
94
+ : Math.min(startObj, startArr);
95
+ if (start < 0) return null;
96
+
97
+ const open = cleaned[start];
98
+ const close = open === "{" ? "}" : "]";
99
+ let depth = 0;
100
+ let inString = false;
101
+ let escape = false;
102
+ for (let i = start; i < cleaned.length; i++) {
103
+ const ch = cleaned[i]!;
104
+ if (escape) {
105
+ escape = false;
106
+ continue;
107
+ }
108
+ if (inString) {
109
+ if (ch === "\\") escape = true;
110
+ else if (ch === '"') inString = false;
111
+ continue;
112
+ }
113
+ if (ch === '"') {
114
+ inString = true;
115
+ continue;
116
+ }
117
+ if (ch === open) depth++;
118
+ else if (ch === close) {
119
+ depth--;
120
+ if (depth === 0) {
121
+ try {
122
+ return JSON.parse(cleaned.slice(start, i + 1));
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Redact CLI argv values that are likely secrets before logging or
134
+ * surfacing in the status bar (`command` field).
135
+ *
136
+ * - Values after `set` / `put` / `login` subcommands
137
+ * - Args that look like Bearer tokens or env-style secret assignments
138
+ *
139
+ * Exported for unit tests.
140
+ */
141
+ export function sanitizeCliArgsForLog(args: readonly string[]): string[] {
142
+ const out = args.map((a) => redactSecretsInText(a));
143
+ const writeVerbs = new Set(["set", "put", "login", "create-token"]);
144
+ for (let i = 0; i < out.length; i++) {
145
+ const token = (args[i] ?? "").toLowerCase();
146
+ if (!writeVerbs.has(token)) continue;
147
+ // `… set <key> <value>` — redact value (index i+2)
148
+ if (i + 2 < out.length) {
149
+ out[i + 2] = "[redacted]";
150
+ }
151
+ // `… put <value>` or single-arg write — redact next arg when no third
152
+ else if (i + 1 < out.length && token !== "set") {
153
+ out[i + 1] = "[redacted]";
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** Best-effort kill of a Bun subprocess (never throws). */
160
+ function killProc(
161
+ proc: { kill: (exitCode?: number) => void; killed?: boolean },
162
+ signal: number = 15 // SIGTERM
163
+ ): void {
164
+ try {
165
+ if (proc.killed) return;
166
+ proc.kill(signal);
167
+ } catch {
168
+ /* process already reaped */
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Convert the structured JSON envelope emitted by `wrangler d1 execute
174
+ * --json` into a normalized {@link DbQueryResult}. Unknown records are
175
+ * skipped; the function never throws on malformed input.
176
+ *
177
+ * Expected envelope shape (one element, the success envelope):
178
+ * [ { "results": [ {...}, {...} ], "success": true,
179
+ * "meta": { "duration": 0.123, "rows_read": 42, ... } } ]
180
+ */
181
+ function parseDbQueryResult(stdout: string): DbQueryResult {
182
+ const cleaned = stdout.trim();
183
+ if (!cleaned) {
184
+ return {
185
+ columns: [],
186
+ rows: [],
187
+ rowCount: 0,
188
+ executionTimeMs: null,
189
+ meta: null,
190
+ };
191
+ }
192
+
193
+ const parsed = tryParseJsonLoose(cleaned);
194
+ if (!Array.isArray(parsed) || parsed.length === 0) {
195
+ return {
196
+ columns: [],
197
+ rows: [],
198
+ rowCount: 0,
199
+ executionTimeMs: null,
200
+ meta: null,
201
+ };
202
+ }
203
+
204
+ // The first envelope is the one we care about.
205
+ const envelope = parsed[0];
206
+ if (!envelope || typeof envelope !== "object") {
207
+ return {
208
+ columns: [],
209
+ rows: [],
210
+ rowCount: 0,
211
+ executionTimeMs: null,
212
+ meta: null,
213
+ };
214
+ }
215
+
216
+ const env = envelope as {
217
+ results?: unknown;
218
+ success?: unknown;
219
+ meta?: unknown;
220
+ };
221
+
222
+ // If wrangler reported success=false, still surface the row data — the
223
+ // error message is already in the bridge's stderr field.
224
+ const rowsRaw = env.results;
225
+ if (!Array.isArray(rowsRaw)) {
226
+ return {
227
+ columns: [],
228
+ rows: [],
229
+ rowCount: 0,
230
+ executionTimeMs: null,
231
+ meta: null,
232
+ };
233
+ }
234
+
235
+ const rows: Record<string, unknown>[] = [];
236
+ for (const r of rowsRaw) {
237
+ if (r && typeof r === "object" && !Array.isArray(r)) {
238
+ rows.push(r as Record<string, unknown>);
239
+ }
240
+ }
241
+
242
+ // Derive column order from the first row's keys.
243
+ const columns =
244
+ rows.length > 0 && rows[0] !== null ? Object.keys(rows[0]) : [];
245
+
246
+ // wrangler's `meta.duration` is reported in seconds (float). Convert to ms.
247
+ let executionTimeMs: number | null = null;
248
+ let meta: Record<string, unknown> | null = null;
249
+ if (env.meta && typeof env.meta === "object" && !Array.isArray(env.meta)) {
250
+ meta = env.meta as Record<string, unknown>;
251
+ const duration = (meta as { duration?: unknown }).duration;
252
+ if (typeof duration === "number" && Number.isFinite(duration)) {
253
+ // Always non-negative; cap at 24h to avoid display glitches.
254
+ const ms = Math.max(0, duration * 1000);
255
+ executionTimeMs = ms < 86_400_000 ? ms : null;
256
+ }
257
+ }
258
+
259
+ return {
260
+ columns,
261
+ rows,
262
+ rowCount: rows.length,
263
+ executionTimeMs,
264
+ meta,
265
+ };
266
+ }
267
+
268
+ /** Compute the {@link QueueDepthStatus} for a given depth + paused flag. */
269
+ function deriveQueueDepthStatus(
270
+ depth: number,
271
+ paused: boolean
272
+ ): QueueDepthStatus {
273
+ if (paused) return "paused";
274
+ if (depth < HEALTHY_THRESHOLD) return "healthy";
275
+ if (depth <= CRITICAL_THRESHOLD) return "backlogged";
276
+ return "critical";
277
+ }
278
+
279
+ /**
280
+ * Convert the structured JSON envelope emitted by the CLI's
281
+ * `hoox monitor queue-depth --json` command into a normalized
282
+ * {@link QueueDepth} array. Unknown queue names are skipped;
283
+ * the bridge never throws on malformed input.
284
+ *
285
+ * Expected envelope shape:
286
+ * { "queues": [ { "queue_name": "...", "producers_total_count": 2, ... } ] }
287
+ */
288
+ function parseQueueDepths(stdout: string): QueueDepth[] {
289
+ const timestamp = new Date().toISOString();
290
+ const cleaned = stdout.trim();
291
+ if (!cleaned) return [];
292
+
293
+ const parsed = tryParseJsonLoose(cleaned);
294
+ if (
295
+ parsed === null ||
296
+ typeof parsed !== "object" ||
297
+ !("queues" in parsed) ||
298
+ !Array.isArray((parsed as { queues: unknown }).queues)
299
+ ) {
300
+ return [];
301
+ }
302
+
303
+ const records = (parsed as { queues: WranglerQueueRecord[] }).queues;
304
+ const result: QueueDepth[] = [];
305
+
306
+ for (const record of records) {
307
+ if (!record || typeof record !== "object") continue;
308
+ const name = record.queue_name ?? record.queue_id;
309
+ if (typeof name !== "string" || name.length === 0) continue;
310
+
311
+ const producers =
312
+ typeof record.producers_total_count === "number"
313
+ ? record.producers_total_count
314
+ : 0;
315
+ const consumers =
316
+ typeof record.consumers_total_count === "number"
317
+ ? record.consumers_total_count
318
+ : 0;
319
+ const paused = record.settings?.delivery_paused === true;
320
+
321
+ // Heuristic depth — see QueueDepthStatus doc-comment for rationale.
322
+ const depth = paused
323
+ ? DEFAULT_QUEUE_MAX
324
+ : Math.max(0, producers * DEPTH_PER_PRODUCER);
325
+
326
+ result.push({
327
+ queueName: name,
328
+ depth,
329
+ max: DEFAULT_QUEUE_MAX,
330
+ status: deriveQueueDepthStatus(depth, paused),
331
+ producers,
332
+ consumers,
333
+ paused,
334
+ timestamp,
335
+ });
336
+ }
337
+
338
+ return result;
339
+ }
340
+
341
+ /**
342
+ * Parse the JSON output emitted by `hoox config secrets list --json`.
343
+ *
344
+ * The CLI emits one of two shapes:
345
+ * - Single worker: { worker: string, secrets: string[] }
346
+ * - All workers: Record<string, string[]>
347
+ *
348
+ * This parser accepts both and normalizes to a flat SecretMetadata array.
349
+ * Values are intentionally omitted — the viewer is strictly read-only.
350
+ * The parser never throws on malformed input.
351
+ */
352
+ function parseSecretsList(stdout: string): SecretMetadata[] {
353
+ const cleaned = stdout.trim();
354
+ if (!cleaned) return [];
355
+
356
+ const parsed = tryParseJsonLoose(cleaned);
357
+ if (parsed === null) return [];
358
+
359
+ // Single-worker envelope: { worker: string, secrets: string[] }
360
+ if (
361
+ parsed !== null &&
362
+ typeof parsed === "object" &&
363
+ "worker" in parsed &&
364
+ "secrets" in parsed
365
+ ) {
366
+ const envelope = parsed as { worker: string; secrets: unknown };
367
+ if (Array.isArray(envelope.secrets)) {
368
+ const result: SecretMetadata[] = [];
369
+ for (const item of envelope.secrets) {
370
+ if (typeof item === "string") {
371
+ result.push({
372
+ name: item,
373
+ type: inferSecretType(item),
374
+ source: "config",
375
+ });
376
+ }
377
+ }
378
+ return result;
379
+ }
380
+ return [];
381
+ }
382
+
383
+ // All-workers envelope: Record<string, string[]>
384
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
385
+ const entries = Object.entries(parsed as Record<string, unknown>);
386
+ const result: SecretMetadata[] = [];
387
+ for (const [, value] of entries) {
388
+ if (Array.isArray(value)) {
389
+ for (const item of value) {
390
+ if (typeof item === "string") {
391
+ result.push({
392
+ name: item,
393
+ type: inferSecretType(item),
394
+ source: "config",
395
+ });
396
+ }
397
+ }
398
+ }
399
+ }
400
+ return result;
401
+ }
402
+
403
+ return [];
404
+ }
405
+
406
+ /**
407
+ * Infer the secret type from its name using naming conventions.
408
+ * Cloudflare secrets store does not expose per-secret type metadata,
409
+ * so we derive it from the key name suffix.
410
+ */
411
+ function inferSecretType(name: string): SecretMetadata["type"] {
412
+ const lower = name.toLowerCase();
413
+ if (lower.includes("key") || lower.includes("api")) return "api_key";
414
+ if (lower.includes("token")) return "token";
415
+ if (lower.includes("password") || lower.includes("passwd")) return "password";
416
+ if (lower.includes("secret")) return "secret";
417
+ return "unknown";
418
+ }
419
+
420
+ /**
421
+ * Subset of the KV manifest row shape that we care about for the viewer.
422
+ * Mirrors the row from `hoox config kv manifest --json`; the bridge
423
+ * always treats every field as optional so it tolerates future schema
424
+ * changes or wrangler not being installed.
425
+ */
426
+ interface KvManifestRecord {
427
+ key?: string;
428
+ type?: "boolean" | "number" | "string";
429
+ secret?: boolean;
430
+ }
431
+
432
+ /**
433
+ * Subset of the wrangler key-listing record shape we care about.
434
+ * We treat every field as optional so we degrade gracefully when fields
435
+ * are missing or wrangler changes its schema.
436
+ */
437
+ interface WranglerKeyRecord {
438
+ name?: string;
439
+ metadata?: { value_size?: number; last_modified?: string } | null;
440
+ }
441
+
442
+ /**
443
+ * Parse the JSON output emitted by `hoox config kv list --json` and merge
444
+ * it with manifest metadata to flag secret keys.
445
+ *
446
+ * The CLI emits one of two shapes:
447
+ * - Array form: `[ { "name": "..." }, ... ]` (current)
448
+ * - Envelope form: `{ "keys": [ { "name": "..." }, ... ] }` (future-proof)
449
+ *
450
+ * This parser accepts both. Unknown rows are skipped; the parser never
451
+ * throws on malformed input — it returns an empty `keys` array instead.
452
+ */
453
+ function parseKvList(
454
+ stdout: string,
455
+ manifest: Map<
456
+ string,
457
+ { type: "boolean" | "number" | "string"; secret: boolean }
458
+ >
459
+ ): KvKey[] {
460
+ const cleaned = stdout.trim();
461
+ if (!cleaned) return [];
462
+
463
+ const parsed = tryParseJsonLoose(cleaned);
464
+ if (parsed === null) return [];
465
+
466
+ let records: unknown;
467
+ if (Array.isArray(parsed)) {
468
+ records = parsed;
469
+ } else if (
470
+ parsed !== null &&
471
+ typeof parsed === "object" &&
472
+ "keys" in parsed &&
473
+ Array.isArray((parsed as { keys: unknown }).keys)
474
+ ) {
475
+ records = (parsed as { keys: unknown }).keys;
476
+ } else {
477
+ return [];
478
+ }
479
+
480
+ const list = records as WranglerKeyRecord[];
481
+ const result: KvKey[] = [];
482
+ for (const record of list) {
483
+ if (!record || typeof record !== "object") continue;
484
+ const name = record.name;
485
+ if (typeof name !== "string" || name.length === 0) continue;
486
+
487
+ const meta = manifest.get(name);
488
+ const valueSize =
489
+ record.metadata && typeof record.metadata.value_size === "number"
490
+ ? record.metadata.value_size
491
+ : null;
492
+ const lastModified =
493
+ record.metadata && typeof record.metadata.last_modified === "string"
494
+ ? record.metadata.last_modified
495
+ : null;
496
+
497
+ result.push({
498
+ name,
499
+ valueSize,
500
+ lastModified,
501
+ isSecret: meta?.secret === true,
502
+ manifestType: meta?.type ?? null,
503
+ });
504
+ }
505
+ return result;
506
+ }
507
+
508
+ /**
509
+ * Parse the manifest emitted by `hoox config kv manifest --json` and
510
+ * return a `Map` keyed by key name. Returns an empty map on any failure
511
+ * — the view still functions, it just cannot flag secret keys.
512
+ */
513
+ function parseKvManifest(
514
+ stdout: string
515
+ ): Map<string, { type: "boolean" | "number" | "string"; secret: boolean }> {
516
+ const map = new Map<
517
+ string,
518
+ { type: "boolean" | "number" | "string"; secret: boolean }
519
+ >();
520
+ const cleaned = stdout.trim();
521
+ if (!cleaned) return map;
522
+
523
+ const parsed = tryParseJsonLoose(cleaned);
524
+ if (parsed === null) return map;
525
+
526
+ // Manifest shape: { namespace: "CONFIG_KV", keys: [{ key, type, default, ... }] }
527
+ const keys =
528
+ parsed !== null &&
529
+ typeof parsed === "object" &&
530
+ "keys" in parsed &&
531
+ Array.isArray((parsed as { keys: unknown }).keys)
532
+ ? ((parsed as { keys: unknown }).keys as KvManifestRecord[])
533
+ : [];
534
+
535
+ for (const record of keys) {
536
+ if (!record || typeof record !== "object") continue;
537
+ const name = record.key;
538
+ if (typeof name !== "string" || name.length === 0) continue;
539
+ const type =
540
+ record.type === "boolean" ||
541
+ record.type === "number" ||
542
+ record.type === "string"
543
+ ? record.type
544
+ : "string";
545
+ map.set(name, { type, secret: record.secret === true });
546
+ }
547
+ return map;
548
+ }
549
+
550
+ /** ANSI escape sequence pattern for stripping terminal color codes */
551
+ // eslint-disable-next-line no-control-regex -- intentional: matches ESC (\x1b) sequences
552
+ const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
553
+
554
+ /** Remove ANSI color escape codes from a string. */
555
+ function stripAnsi(input: string): string {
556
+ return input.replace(ANSI_PATTERN, "");
557
+ }
558
+
559
+ /**
560
+ * Subset of the wrangler `queues list --json` record shape we care about.
561
+ * We treat every field as optional so we degrade gracefully when fields
562
+ * are missing or wrangler changes its schema.
563
+ */
564
+ interface WranglerQueueRecord {
565
+ queue_name?: string;
566
+ queue_id?: string;
567
+ producers_total_count?: number;
568
+ consumers_total_count?: number;
569
+ settings?: {
570
+ delivery_paused?: boolean;
571
+ message_retention_period?: number;
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Convert `hoox monitor kill-switch <action>` output into a structured
577
+ * {@link KillSwitchStatus}. The CLI's `show` subcommand emits a colored
578
+ * human-readable string even in --json mode (its implementation only
579
+ * switches the format for set actions), so we detect the engaged/released
580
+ * state from the message text.
581
+ */
582
+ function parseKillSwitchStatus(
583
+ action: KillSwitchAction,
584
+ stdout: string
585
+ ): KillSwitchStatus {
586
+ const timestamp = new Date().toISOString();
587
+ const cleaned = stripAnsi(stdout).trim();
588
+ const lowered = cleaned.toLowerCase();
589
+
590
+ if (action === "engage") {
591
+ return { engaged: true, rawValue: "true", timestamp, action };
592
+ }
593
+ if (action === "release") {
594
+ return { engaged: false, rawValue: "false", timestamp, action };
595
+ }
596
+
597
+ // 'show' — derive state from the human-readable status line
598
+ if (lowered.includes("kill switch is on") || lowered.includes("halted")) {
599
+ return { engaged: true, rawValue: "true", timestamp, action };
600
+ }
601
+ if (
602
+ lowered.includes("kill switch is off") ||
603
+ (lowered.includes("active") && !lowered.includes("halted"))
604
+ ) {
605
+ return { engaged: false, rawValue: "false", timestamp, action };
606
+ }
607
+
608
+ // Fallback — look for an explicit "value: <v>" or "(not set)" marker
609
+ const valueMatch = cleaned.match(/value:\s*(\S+)/i);
610
+ if (valueMatch) {
611
+ const raw = valueMatch[1] ?? null;
612
+ return {
613
+ engaged: raw === "true",
614
+ rawValue: raw,
615
+ timestamp,
616
+ action,
617
+ };
618
+ }
619
+
620
+ // Unknown output — treat as released (fail-open for display, but flag raw)
621
+ return { engaged: false, rawValue: null, timestamp, action };
622
+ }
623
+
624
+ /** Handle for one in-flight CLI spawn (abort + hard kill). */
625
+ interface TrackedCommand {
626
+ aborter: AbortController;
627
+ kill: () => void;
628
+ }
629
+
630
+ class CliBridgeImpl {
631
+ private binaryPath: string | null = null;
632
+ /** Cached monorepo root from {@link findMonorepoRoot} (path resolution). */
633
+ private monorepoRoot: string | null = null;
634
+ /** Maps tag → set of tracked commands for concurrent runs with the same tag */
635
+ private activeCommands = new Map<string, Set<TrackedCommand>>();
636
+ /**
637
+ * Global error sinks invoked on every CLI bridge failure. Listeners
638
+ * receive a fully-formed `CliErrorDetails` payload so callers don't
639
+ * need to translate `CliResult.errorType` themselves. The status bar
640
+ * registers one sink at app startup to surface the real diagnostic
641
+ * context (command, exit code, stderr) regardless of which view
642
+ * triggered the command.
643
+ */
644
+ private errorSinks = new Set<(details: CliErrorDetails) => void>();
645
+
646
+ async resolveBinary(): Promise<string> {
647
+ if (this.binaryPath) return this.binaryPath;
648
+
649
+ // Prefer explicit env, then PATH (`hx` is the short alias; `hoox` may be
650
+ // a shell alias that Bun.which still resolves via ~/.bun/bin).
651
+ const envBin = process.env.HOOX_CLI?.trim();
652
+ if (envBin) {
653
+ this.binaryPath = envBin;
654
+ return envBin;
655
+ }
656
+
657
+ for (const name of ["hx", "hoox"] as const) {
658
+ const fromPath = Bun.which(name);
659
+ if (fromPath) {
660
+ this.binaryPath = fromPath;
661
+ return fromPath;
662
+ }
663
+ }
664
+
665
+ const root = await this.findMonorepoRoot();
666
+
667
+ const nodeBin = path.join(root, "node_modules", ".bin", "hoox");
668
+ if (await Bun.file(nodeBin).exists()) {
669
+ this.binaryPath = nodeBin;
670
+ return nodeBin;
671
+ }
672
+
673
+ const cliBin = path.join(root, "packages", "cli", "bin", "hoox.js");
674
+ if (await Bun.file(cliBin).exists()) {
675
+ this.binaryPath = cliBin;
676
+ return cliBin;
677
+ }
678
+
679
+ throw new Error(
680
+ "hoox binary not found — install the CLI (`bun install -g ./packages/cli`) or set HOOX_CLI"
681
+ );
682
+ }
683
+
684
+ /** Track a new command under a tag. Returns a cleanup function. */
685
+ private trackCommand(tag: string, tracked: TrackedCommand): () => void {
686
+ const controllers = this.activeCommands.get(tag) ?? new Set();
687
+ controllers.add(tracked);
688
+ this.activeCommands.set(tag, controllers);
689
+ return () => {
690
+ const set = this.activeCommands.get(tag);
691
+ if (set) {
692
+ set.delete(tracked);
693
+ if (set.size === 0) this.activeCommands.delete(tag);
694
+ }
695
+ };
696
+ }
697
+
698
+ invalidateCache(): void {
699
+ this.binaryPath = null;
700
+ this.monorepoRoot = null;
701
+ }
702
+
703
+ /**
704
+ * Register a global error sink. The sink is invoked **synchronously**
705
+ * after every `exec()` call that produced a non-null `errorType`, with
706
+ * a fully-formed `CliErrorDetails` payload (command, exit code, stderr,
707
+ * stdout, classification, duration, timestamp).
708
+ *
709
+ * Returns an unsubscribe function. Safe to call from React `useEffect`
710
+ * — typical pattern is to register on mount, unsubscribe on unmount.
711
+ *
712
+ * Sinks must not throw; exceptions are caught and logged so a buggy
713
+ * listener can never break the bridge.
714
+ */
715
+ onError(sink: (details: CliErrorDetails) => void): () => void {
716
+ this.errorSinks.add(sink);
717
+ return () => {
718
+ this.errorSinks.delete(sink);
719
+ };
720
+ }
721
+
722
+ /**
723
+ * Build a `CliErrorDetails` snapshot from a finished `CliResult` and
724
+ * fan it out to every registered sink. No-op on success.
725
+ *
726
+ * `timestamp` is captured at the moment the command finished (not at
727
+ * registration time) so multiple commands get distinct timestamps.
728
+ */
729
+ private notifyError(result: CliResult<unknown>): void {
730
+ if (result.errorType === null) return;
731
+ const details: CliErrorDetails = {
732
+ command: result.command,
733
+ exitCode: result.exitCode,
734
+ stderr: result.stderr,
735
+ stdout: result.stdout,
736
+ errorType: result.errorType,
737
+ timestamp: Date.now(),
738
+ duration: result.duration,
739
+ };
740
+ for (const sink of this.errorSinks) {
741
+ try {
742
+ sink(details);
743
+ } catch (err) {
744
+ // Defensive: a buggy sink must never break the bridge.
745
+
746
+ console.error("[cli-bridge] error sink threw:", err);
747
+ }
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Locate the hoox monorepo (local checkout or $HOME/.hoox/repo).
753
+ * Prefers resolveHooxRuntimeRoot so the TUI works outside the checkout.
754
+ * Result is cached for the process lifetime (invalidated via invalidateCache).
755
+ */
756
+ private async findMonorepoRoot(): Promise<string> {
757
+ if (this.monorepoRoot) return this.monorepoRoot;
758
+
759
+ const runtime = resolveHooxRuntimeRoot();
760
+ if (runtime.root) {
761
+ this.monorepoRoot = runtime.root;
762
+ return runtime.root;
763
+ }
764
+
765
+ // Fallback: walk-up for any package.json with workspaces
766
+ let dir = process.cwd();
767
+ while (true) {
768
+ try {
769
+ const pkgPath = path.join(dir, "package.json");
770
+ const pkg = JSON.parse(await Bun.file(pkgPath).text()) as {
771
+ workspaces?: string[];
772
+ };
773
+ if (pkg.workspaces) {
774
+ this.monorepoRoot = dir;
775
+ return dir;
776
+ }
777
+ } catch {
778
+ /* not found or invalid JSON */
779
+ }
780
+ const parent = path.dirname(dir);
781
+ if (parent === dir) {
782
+ // Last resort: global managed clone path even if markers incomplete
783
+ const globalRepo = getHooxRepoPath();
784
+ if (await Bun.file(path.join(globalRepo, "package.json")).exists()) {
785
+ this.monorepoRoot = globalRepo;
786
+ return globalRepo;
787
+ }
788
+ throw new Error(
789
+ "Monorepo root not found — run `hoox doctor --fix-runtime` or set HOOX_REPO"
790
+ );
791
+ }
792
+ dir = parent;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Truncate long captured output to a safe cap, preserving the tail
798
+ * (where most CLI errors appear) and adding a marker.
799
+ */
800
+ private truncateOutput(text: string): string {
801
+ if (text.length <= MAX_OUTPUT_CHARS) return text;
802
+ const tail = text.slice(text.length - MAX_OUTPUT_CHARS + 64);
803
+ return `…[${text.length - tail.length} chars truncated]…\n${tail}`;
804
+ }
805
+
806
+ /**
807
+ * Public entry point. Wraps {@link execCore} with error-sink
808
+ * notification so every caller — convenience methods and direct
809
+ * invocations alike — propagates failures to registered listeners
810
+ * without each caller having to do the bookkeeping.
811
+ */
812
+ async exec<T>(args: string[], options?: ExecOptions): Promise<CliResult<T>> {
813
+ const result = await this.execCore<T>(args, options);
814
+ this.notifyError(result);
815
+ void tuiDevLog.debug(
816
+ "cli-bridge",
817
+ result.success ? "exec ok" : "exec fail",
818
+ {
819
+ tag: options?.tag ?? args[0] ?? "unknown",
820
+ // Never log raw argv values that may be secrets (kv set, tokens, …)
821
+ args: sanitizeCliArgsForLog(args),
822
+ success: result.success,
823
+ exitCode: result.exitCode,
824
+ durationMs: Math.round(result.duration),
825
+ errorType: result.errorType ?? null,
826
+ stderr: result.success
827
+ ? undefined
828
+ : redactSecretsInText(result.stderr || "") || undefined,
829
+ }
830
+ );
831
+ return result;
832
+ }
833
+
834
+ /**
835
+ * Internal exec implementation. Always returns a fully-populated
836
+ * `CliResult` — never throws. All failure modes (spawn, abort,
837
+ * timeout, non-zero exit, JSON parse) are classified into
838
+ * `errorType` so the wrapper can fan out to listeners.
839
+ *
840
+ * On timeout/abort the subprocess is SIGTERM'd then SIGKILL'd after
841
+ * {@link KILL_GRACE_MS} so hung CLI children cannot pin the TUI process.
842
+ */
843
+ private async execCore<T>(
844
+ args: string[],
845
+ options?: ExecOptions
846
+ ): Promise<CliResult<T>> {
847
+ const start = performance.now();
848
+ const tag = options?.tag ?? args[0] ?? "unknown";
849
+ const aborter = new AbortController();
850
+
851
+ const cmdArgs = [...args];
852
+ if (options?.json) cmdArgs.push("--json");
853
+ if (options?.yes) cmdArgs.push("--yes");
854
+
855
+ // Secret-safe command label for status bar / error panel (never raw values).
856
+ const baseCommand = `hoox ${sanitizeCliArgsForLog(args).join(" ")}`;
857
+
858
+ // Early exit if the caller already aborted.
859
+ if (options?.signal?.aborted) {
860
+ return {
861
+ success: false,
862
+ exitCode: -1,
863
+ stdout: "",
864
+ stderr: "Command was aborted",
865
+ data: null,
866
+ duration: performance.now() - start,
867
+ command: baseCommand,
868
+ errorType: "aborted",
869
+ };
870
+ }
871
+
872
+ let proc: ReturnType<typeof Bun.spawn> | null = null;
873
+ let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
874
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
875
+ let timedOut = false;
876
+ let externallyAborted = false;
877
+
878
+ const hardKill = () => {
879
+ if (!proc) return;
880
+ killProc(proc, 15); // SIGTERM
881
+ if (forceKillTimer) return;
882
+ forceKillTimer = setTimeout(() => {
883
+ if (proc) killProc(proc, 9); // SIGKILL
884
+ }, KILL_GRACE_MS);
885
+ };
886
+
887
+ const tracked: TrackedCommand = {
888
+ aborter,
889
+ kill: hardKill,
890
+ };
891
+ const cleanup = this.trackCommand(tag, tracked);
892
+
893
+ const onExternalAbort = () => {
894
+ externallyAborted = true;
895
+ hardKill();
896
+ if (!aborter.signal.aborted) aborter.abort();
897
+ };
898
+ options?.signal?.addEventListener("abort", onExternalAbort, {
899
+ once: true,
900
+ });
901
+
902
+ try {
903
+ const binary = await this.resolveBinary();
904
+ const command = `${binary} ${sanitizeCliArgsForLog(cmdArgs).join(" ")}`;
905
+
906
+ try {
907
+ proc = Bun.spawn([binary, ...cmdArgs], {
908
+ stdout: "pipe",
909
+ stderr: "pipe",
910
+ signal: aborter.signal,
911
+ });
912
+ } catch (spawnErr) {
913
+ const stderr = (spawnErr as Error).message;
914
+ return {
915
+ success: false,
916
+ exitCode: -1,
917
+ stdout: "",
918
+ stderr: this.truncateOutput(stderr),
919
+ data: null,
920
+ duration: performance.now() - start,
921
+ command,
922
+ errorType: "spawn-error",
923
+ };
924
+ }
925
+
926
+ const timeoutMs = options?.timeout ?? 30_000;
927
+ timeoutTimer = setTimeout(() => {
928
+ timedOut = true;
929
+ hardKill();
930
+ if (!aborter.signal.aborted) aborter.abort();
931
+ }, timeoutMs);
932
+
933
+ let stderrResult = "";
934
+ const readStderr = async () => {
935
+ const reader = (proc!.stderr as ReadableStream<Uint8Array>).getReader();
936
+ const decoder = new TextDecoder();
937
+ try {
938
+ while (true) {
939
+ const { done, value } = await reader.read();
940
+ if (done) break;
941
+ const chunk = decoder.decode(value, { stream: true });
942
+ stderrResult += chunk;
943
+ options?.onProgress?.(chunk);
944
+ }
945
+ } catch (streamErr) {
946
+ // Stream closing due to abort/kill is expected
947
+ if (!aborter.signal.aborted && !timedOut) throw streamErr;
948
+ }
949
+ };
950
+
951
+ const [stdout] = await Promise.all([
952
+ new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
953
+ readStderr(),
954
+ ]);
955
+
956
+ const exitCode = await proc.exited;
957
+
958
+ // Streams are fully consumed above (getReader / Response.text).
959
+
960
+ let data: T | null = null;
961
+ if (options?.json && exitCode === 0 && stdout.trim()) {
962
+ const parsed = tryParseJsonLoose(stdout);
963
+ if (parsed !== null) data = parsed as T;
964
+ }
965
+
966
+ // Classify the failure (success leaves errorType=null).
967
+ const errorType: CliErrorType | null =
968
+ exitCode === 0
969
+ ? null
970
+ : timedOut
971
+ ? "timeout"
972
+ : externallyAborted || aborter.signal.aborted
973
+ ? "aborted"
974
+ : "non-zero-exit";
975
+
976
+ return {
977
+ success: exitCode === 0,
978
+ exitCode,
979
+ stdout: this.truncateOutput(stdout),
980
+ stderr: this.truncateOutput(stderrResult),
981
+ data,
982
+ duration: performance.now() - start,
983
+ command,
984
+ errorType,
985
+ };
986
+ } catch (err) {
987
+ hardKill();
988
+ const isAbort = err instanceof Error && err.name === "AbortError";
989
+ // Distinguish "binary not found" from generic spawn-time errors so
990
+ // the status bar can suggest `bun install` rather than a runtime fix.
991
+ const errorType: CliErrorType = timedOut
992
+ ? "timeout"
993
+ : isAbort || externallyAborted || aborter.signal.aborted
994
+ ? "aborted"
995
+ : (err as Error).message.includes("not found")
996
+ ? "binary-not-found"
997
+ : "spawn-error";
998
+ return {
999
+ success: false,
1000
+ exitCode: -1,
1001
+ stdout: "",
1002
+ stderr: this.truncateOutput(
1003
+ timedOut || isAbort || externallyAborted
1004
+ ? "Command timed out or was aborted"
1005
+ : (err as Error).message
1006
+ ),
1007
+ data: null,
1008
+ duration: performance.now() - start,
1009
+ command: baseCommand,
1010
+ errorType,
1011
+ };
1012
+ } finally {
1013
+ if (timeoutTimer) clearTimeout(timeoutTimer);
1014
+ if (forceKillTimer) clearTimeout(forceKillTimer);
1015
+ options?.signal?.removeEventListener("abort", onExternalAbort);
1016
+ cleanup();
1017
+ }
1018
+ }
1019
+
1020
+ abort(tag: string): void {
1021
+ const tracked = this.activeCommands.get(tag);
1022
+ if (tracked) {
1023
+ for (const entry of tracked) {
1024
+ entry.kill();
1025
+ entry.aborter.abort();
1026
+ }
1027
+ this.activeCommands.delete(tag);
1028
+ }
1029
+ }
1030
+
1031
+ dispose(): void {
1032
+ for (const [, tracked] of this.activeCommands) {
1033
+ for (const entry of tracked) {
1034
+ entry.kill();
1035
+ entry.aborter.abort();
1036
+ }
1037
+ }
1038
+ this.activeCommands.clear();
1039
+ }
1040
+
1041
+ deployAll(onProgress?: (chunk: string) => void): Promise<CliResult<unknown>> {
1042
+ return this.exec(["deploy", "all"], {
1043
+ json: true,
1044
+ yes: true,
1045
+ timeout: 120_000,
1046
+ onProgress,
1047
+ tag: "deploy:all",
1048
+ });
1049
+ }
1050
+
1051
+ deployWorker(
1052
+ name: string,
1053
+ onProgress?: (chunk: string) => void
1054
+ ): Promise<CliResult<unknown>> {
1055
+ return this.exec(["deploy", "worker", name], {
1056
+ json: true,
1057
+ yes: true,
1058
+ timeout: 60_000,
1059
+ onProgress,
1060
+ tag: `deploy:${name}`,
1061
+ });
1062
+ }
1063
+
1064
+ checkHealth(): Promise<CliResult<unknown>> {
1065
+ return this.exec(["check", "health"], {
1066
+ json: true,
1067
+ timeout: 15_000,
1068
+ tag: "check:health",
1069
+ });
1070
+ }
1071
+
1072
+ /**
1073
+ * Run health check with auto-fix probe via `hoox check health --fix`.
1074
+ *
1075
+ * Returns health check results with fix recommendations. Unlike `checkFix`,
1076
+ * this does not apply any repairs — it only checks connectivity and suggests
1077
+ * running `hoox check fix` if issues were found.
1078
+ *
1079
+ * @returns A CliResult with `data` containing a `HealthCheckResult[]` when
1080
+ * successful (same shape as `checkHealth`).
1081
+ */
1082
+ checkHealthFix(): Promise<CliResult<unknown>> {
1083
+ return this.exec(["check", "health", "--fix"], {
1084
+ json: true,
1085
+ timeout: 15_000,
1086
+ tag: "check:health:fix",
1087
+ });
1088
+ }
1089
+
1090
+ /**
1091
+ * Run auto-repair via `hoox check fix` to apply non-destructive fixes.
1092
+ *
1093
+ * Performs common repairs:
1094
+ * 1. Creates missing .dev.vars placeholder files
1095
+ * 2. Adds missing nodejs_compat compatibility flags
1096
+ * 3. Adds missing name fields to wrangler.jsonc
1097
+ *
1098
+ * @returns A CliResult with `data` containing a `FixReport` with the
1099
+ * fix action results (applied, failed, or skipped).
1100
+ */
1101
+ checkFix(): Promise<CliResult<unknown>> {
1102
+ return this.exec(["check", "fix"], {
1103
+ json: true,
1104
+ yes: true,
1105
+ timeout: 60_000,
1106
+ tag: "check:fix",
1107
+ });
1108
+ }
1109
+
1110
+ workerLogs(name: string): Promise<CliResult<unknown>> {
1111
+ return this.exec(["logs", "worker", name, "--no-follow"], {
1112
+ json: true,
1113
+ timeout: 30_000,
1114
+ tag: `logs:${name}`,
1115
+ });
1116
+ }
1117
+
1118
+ configShow(): Promise<CliResult<unknown>> {
1119
+ return this.exec(["config", "show"], {
1120
+ json: true,
1121
+ timeout: 10_000,
1122
+ tag: "config:show",
1123
+ });
1124
+ }
1125
+
1126
+ configValidate(): Promise<CliResult<unknown>> {
1127
+ return this.exec(["config", "env", "validate"], {
1128
+ json: true,
1129
+ timeout: 10_000,
1130
+ tag: "config:validate",
1131
+ });
1132
+ }
1133
+
1134
+ monitorStatus(): Promise<CliResult<unknown>> {
1135
+ return this.exec(["check", "health"], {
1136
+ json: true,
1137
+ timeout: 15_000,
1138
+ tag: "check:health",
1139
+ });
1140
+ }
1141
+
1142
+ rebuild(onProgress?: (chunk: string) => void): Promise<CliResult<unknown>> {
1143
+ return this.exec(["repair", "rebuild"], {
1144
+ json: true,
1145
+ yes: true,
1146
+ timeout: 120_000,
1147
+ onProgress,
1148
+ tag: "repair:rebuild",
1149
+ });
1150
+ }
1151
+
1152
+ repairWorker(
1153
+ name: string,
1154
+ onProgress?: (chunk: string) => void
1155
+ ): Promise<CliResult<unknown>> {
1156
+ return this.exec(["repair", "worker", name], {
1157
+ json: true,
1158
+ yes: true,
1159
+ timeout: 60_000,
1160
+ onProgress,
1161
+ tag: `repair:${name}`,
1162
+ });
1163
+ }
1164
+
1165
+ checkSetup(): Promise<CliResult<unknown>> {
1166
+ return this.exec(["check", "setup"], {
1167
+ json: true,
1168
+ timeout: 20_000,
1169
+ tag: "check:setup",
1170
+ });
1171
+ }
1172
+
1173
+ /**
1174
+ * Control the global trade kill switch via `hoox monitor kill-switch <action>`.
1175
+ *
1176
+ * Accepts semantic action names ("show" | "engage" | "release") — these are
1177
+ * translated internally to the CLI's "on"/"off" verbs.
1178
+ *
1179
+ * @param action - "show" reads current state, "engage" halts trading,
1180
+ * "release" resumes trading.
1181
+ * @returns A CliResult with `data` containing a structured {engaged, rawValue, timestamp}
1182
+ * payload. The CLI's `show` subcommand emits a colored human-readable string
1183
+ * even in --json mode, so we parse stdout to derive `engaged`.
1184
+ */
1185
+ monitorKillSwitch(
1186
+ action: KillSwitchAction
1187
+ ): Promise<CliResult<KillSwitchStatus>> {
1188
+ // Map semantic action → CLI verb
1189
+ const cliAction =
1190
+ action === "engage" ? "on" : action === "release" ? "off" : "show";
1191
+
1192
+ return this.exec(["monitor", "kill-switch", cliAction], {
1193
+ json: true,
1194
+ yes: true,
1195
+ timeout: 15_000,
1196
+ tag: `monitor:kill-switch:${action}`,
1197
+ }).then((result) => {
1198
+ if (!result.success) {
1199
+ return { ...result, data: null };
1200
+ }
1201
+ return {
1202
+ ...result,
1203
+ data: parseKillSwitchStatus(action, result.stdout),
1204
+ };
1205
+ });
1206
+ }
1207
+
1208
+ /**
1209
+ * Read Cloudflare Queue backlog pressure via `hoox monitor queue-depth`.
1210
+ *
1211
+ * The CLI runs `wrangler queues list --json` and re-emits a normalized
1212
+ * `{ queues: [...] }` envelope. We parse that envelope into a fully-typed
1213
+ * {@link QueueDepth} array so the view can render directly without
1214
+ * knowing the wrangler/CLI plumbing.
1215
+ *
1216
+ * Note: `wrangler queues list` does not expose real-time message counts.
1217
+ * `depth` is a heuristic estimate derived from the producer count (each
1218
+ * producer is assumed to enqueue ~100 messages on average). See the
1219
+ * {@link QueueDepth} doc for the full status model.
1220
+ *
1221
+ * @returns A CliResult with `data` containing the array of queues. The
1222
+ * data is `null` on failure (e.g., wrangler not installed or
1223
+ * not authenticated). On success with zero queues, `data` is
1224
+ * an empty array — not `null`.
1225
+ */
1226
+ monitorQueueDepth(): Promise<CliResult<QueueDepth[]>> {
1227
+ return this.exec(["monitor", "queue-depth"], {
1228
+ json: true,
1229
+ timeout: 20_000,
1230
+ tag: "monitor:queue-depth",
1231
+ }).then((result) => {
1232
+ if (!result.success) {
1233
+ return { ...result, data: null };
1234
+ }
1235
+ return {
1236
+ ...result,
1237
+ data: parseQueueDepths(result.stdout),
1238
+ };
1239
+ });
1240
+ }
1241
+
1242
+ /**
1243
+ * Execute a read-only D1 SQL query via `hoox db query <sql>`.
1244
+ *
1245
+ * The CLI runs `wrangler d1 execute <db> --command <sql> --json` and
1246
+ * passes the envelope through. We:
1247
+ * 1. Run {@link validateReadOnlySql} on the SQL *first* — if the
1248
+ * caller passed a non-SELECT, the bridge returns a structured
1249
+ * "non-zero-exit" CliResult with the validation reason in stderr
1250
+ * and never spawns the CLI. This is the TUI's read-only guarantee.
1251
+ * 2. On CLI success, parse the wrangler envelope into a normalized
1252
+ * {@link DbQueryResult} (columns, rows, execution time).
1253
+ *
1254
+ * The validation step is intentionally duplicated on the client (the
1255
+ * CLI also has its own; see `hoox db query --help`). Defence in depth.
1256
+ *
1257
+ * @param sql - The SQL string. Must be a read-only statement.
1258
+ * @returns A CliResult with `data` containing a {@link DbQueryResult} on
1259
+ * success, or `null` on any failure (validation, spawn, or
1260
+ * wrangler error). The bridge never throws.
1261
+ */
1262
+ dbQuery(sql: string): Promise<CliResult<DbQueryResult>> {
1263
+ const trimmed = (sql ?? "").trim();
1264
+ const validation = validateReadOnlySql(trimmed);
1265
+ if (!validation.readonly) {
1266
+ // Refuse to spawn the CLI; return a synthetic non-zero-exit result
1267
+ // with the validation reason in stderr. The view's existing error
1268
+ // rendering surfaces this without any special-casing.
1269
+ const reason = (validation as { reason: string }).reason;
1270
+ // Truncate SQL in the command label so long payloads don't bloat the bar.
1271
+ const sqlPreview =
1272
+ trimmed.length > 120 ? `${trimmed.slice(0, 117)}…` : trimmed;
1273
+ const result: CliResult<DbQueryResult> = {
1274
+ success: false,
1275
+ exitCode: 1,
1276
+ stdout: "",
1277
+ stderr: `Read-only validation failed: ${reason}`,
1278
+ data: null,
1279
+ duration: 0,
1280
+ command: `hoox db query ${JSON.stringify(sqlPreview)}`,
1281
+ errorType: "non-zero-exit",
1282
+ };
1283
+ this.notifyError(result);
1284
+ return Promise.resolve(result);
1285
+ }
1286
+
1287
+ return this.exec(["db", "query", trimmed], {
1288
+ json: true,
1289
+ timeout: 30_000,
1290
+ tag: "db:query",
1291
+ }).then((result) => {
1292
+ if (!result.success) {
1293
+ return { ...result, data: null };
1294
+ }
1295
+ return {
1296
+ ...result,
1297
+ data: parseDbQueryResult(result.stdout),
1298
+ };
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Read the Cloudflare CONFIG_KV namespace via `hoox config kv list` and
1304
+ * merge the result with the manifest (`hoox config kv manifest`) so
1305
+ * secret keys can be flagged in the UI.
1306
+ *
1307
+ * The bridge fires both commands in parallel — they are independent
1308
+ * reads and the view can render the key list as soon as it arrives
1309
+ * (manifest data enriches the rendered rows but is not required).
1310
+ *
1311
+ * Security note: this method returns *names* and metadata only. To
1312
+ * fetch an individual value the caller should use
1313
+ * {@link CliBridgeImpl.configKvGet}. The KV *viewer* is read-only;
1314
+ * worker-settings may write non-secret keys via
1315
+ * {@link CliBridgeImpl.configKvSet}. Secrets remain CLI-only.
1316
+ *
1317
+ * @returns A CliResult with `data` containing a {@link KvKeySnapshot}.
1318
+ * `data` is `null` on failure (CLI binary not found, wrangler
1319
+ * not installed, etc.). On success with zero keys, `data.keys`
1320
+ * is an empty array.
1321
+ */
1322
+ configKvList(): Promise<CliResult<KvKeySnapshot>> {
1323
+ return Promise.all([
1324
+ this.exec(["config", "kv", "list"], {
1325
+ json: true,
1326
+ timeout: 20_000,
1327
+ tag: "config:kv:list",
1328
+ }),
1329
+ this.exec(["config", "kv", "manifest"], {
1330
+ json: true,
1331
+ timeout: 20_000,
1332
+ tag: "config:kv:manifest",
1333
+ }),
1334
+ ]).then(([listResult, manifestResult]) => {
1335
+ // If the primary list command failed, surface its error verbatim —
1336
+ // the manifest failure is secondary and the view can still render
1337
+ // an empty manifest cross-reference.
1338
+ if (!listResult.success) {
1339
+ return {
1340
+ ...listResult,
1341
+ data: null,
1342
+ };
1343
+ }
1344
+ const manifest = manifestResult.success
1345
+ ? parseKvManifest(manifestResult.stdout)
1346
+ : new Map<
1347
+ string,
1348
+ { type: "boolean" | "number" | "string"; secret: boolean }
1349
+ >();
1350
+ const keys = parseKvList(listResult.stdout, manifest);
1351
+ // Extract namespace ID from the manifest envelope when available,
1352
+ // otherwise leave it null. The view only needs it for display.
1353
+ const nsId = extractNamespaceId(manifestResult.stdout);
1354
+ const snapshot: KvKeySnapshot = {
1355
+ keys,
1356
+ timestamp: new Date().toISOString(),
1357
+ namespaceId: nsId,
1358
+ };
1359
+ return {
1360
+ ...listResult,
1361
+ data: snapshot,
1362
+ };
1363
+ });
1364
+ }
1365
+
1366
+ /**
1367
+ * Fetch the value of a single KV key via `hoox config kv get <key>`.
1368
+ *
1369
+ * This is a separate method (not bundled into `configKvList`) so the
1370
+ * viewer / worker-settings can lazy-load individual values. Writes for
1371
+ * non-secret settings go through {@link CliBridgeImpl.configKvSet}
1372
+ * (worker-settings view only).
1373
+ *
1374
+ * @param key - The KV key name to look up.
1375
+ * @returns A CliResult with `data` containing the string value
1376
+ * (`null` when the key is missing, empty string, or when the
1377
+ * command failed).
1378
+ */
1379
+ configKvGet(key: string): Promise<CliResult<string | null>> {
1380
+ return this.exec(["config", "kv", "get", key], {
1381
+ json: true,
1382
+ timeout: 15_000,
1383
+ tag: `config:kv:get:${key}`,
1384
+ }).then((result) => {
1385
+ if (!result.success) {
1386
+ // Missing key is not a hard error — the CLI exits non-zero with
1387
+ // a "not found" message and the viewer should display an empty
1388
+ // value rather than an error pill.
1389
+ const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
1390
+ if (combined.includes("not found")) {
1391
+ return { ...result, data: null, success: true };
1392
+ }
1393
+ return { ...result, data: null };
1394
+ }
1395
+ const cleaned = result.stdout.replace(/\n$/, "").trim();
1396
+ return {
1397
+ ...result,
1398
+ data: cleaned.length > 0 ? cleaned : null,
1399
+ };
1400
+ });
1401
+ }
1402
+
1403
+ /**
1404
+ * Write a CONFIG_KV key via `hoox config kv set <key> <value>`.
1405
+ * Used by the worker-settings view (dashboard.jsonc fields).
1406
+ * Prefer for non-secret settings only — secrets stay CLI-only.
1407
+ */
1408
+ configKvSet(key: string, value: string): Promise<CliResult<null>> {
1409
+ return this.exec(["config", "kv", "set", key, value], {
1410
+ json: false,
1411
+ timeout: 20_000,
1412
+ tag: `config:kv:set:${key}`,
1413
+ }).then((result) => ({
1414
+ ...result,
1415
+ data: null,
1416
+ }));
1417
+ }
1418
+
1419
+ /**
1420
+ * Read the list of secrets declared in wrangler.jsonc via
1421
+ * `hoox config secrets list --json`.
1422
+ *
1423
+ * Security: this method returns *names* and *inferred types* only.
1424
+ * No secret values are ever fetched or displayed — the viewer is
1425
+ * strictly read-only and values are not available via the CLI in
1426
+ * any case (`hoox config secrets list` only returns names).
1427
+ *
1428
+ * @returns A CliResult with `data` containing a {@link SecretsSnapshot}.
1429
+ * `data` is `null` on failure (CLI binary not found, wrangler
1430
+ * not installed, etc.). On success with zero secrets, `data.secrets`
1431
+ * is an empty array.
1432
+ */
1433
+ configSecretsList(): Promise<CliResult<SecretsSnapshot>> {
1434
+ return this.exec(["config", "secrets", "list"], {
1435
+ json: true,
1436
+ timeout: 20_000,
1437
+ tag: "config:secrets:list",
1438
+ }).then((result) => {
1439
+ if (!result.success) {
1440
+ return { ...result, data: null };
1441
+ }
1442
+ const secrets = parseSecretsList(result.stdout);
1443
+ const snapshot: SecretsSnapshot = {
1444
+ secrets,
1445
+ timestamp: new Date().toISOString(),
1446
+ };
1447
+ return { ...result, data: snapshot };
1448
+ });
1449
+ }
1450
+
1451
+ /**
1452
+ * Check the health of all AI model providers via `hoox agent health --json`.
1453
+ *
1454
+ * The CLI command queries configured AI providers (Workers AI, OpenAI,
1455
+ * Anthropic, Google, Azure) and returns their operational status, latency,
1456
+ * and usage metrics. This is used by the TUI dashboard to display a
1457
+ * real-time health overview of all AI integrations.
1458
+ *
1459
+ * @returns A CliResult with `data` containing an {@link AgentHealthResult}
1460
+ * on success, or `null` on failure. On success with zero providers,
1461
+ * `data.providers` is an empty array — not `null`.
1462
+ */
1463
+ agentHealthCheck(): Promise<CliResult<AgentHealthResult>> {
1464
+ return this.exec(["agent", "health"], {
1465
+ json: true,
1466
+ timeout: 30_000,
1467
+ tag: "agent:health",
1468
+ }).then((result) => {
1469
+ if (!result.success) {
1470
+ return { ...result, data: null };
1471
+ }
1472
+ return {
1473
+ ...result,
1474
+ data: parseAgentHealth(result.stdout),
1475
+ };
1476
+ });
1477
+ }
1478
+
1479
+ /**
1480
+ * Probe pyne-worker via `hoox pyne health --json`.
1481
+ *
1482
+ * Used by the TUI dashboard to show PYNE edge evaluate availability
1483
+ * without requiring a separate view.
1484
+ */
1485
+ pyneHealthCheck(): Promise<CliResult<PyneHealthResult>> {
1486
+ return this.exec(["pyne", "health"], {
1487
+ json: true,
1488
+ timeout: 15_000,
1489
+ tag: "pyne:health",
1490
+ }).then((result) => {
1491
+ if (!result.success) {
1492
+ return {
1493
+ ...result,
1494
+ data: {
1495
+ worker: "pyne-worker",
1496
+ url: "",
1497
+ status: "down" as const,
1498
+ error: result.stderr || result.stdout || "probe failed",
1499
+ timestamp: new Date().toISOString(),
1500
+ },
1501
+ };
1502
+ }
1503
+ return {
1504
+ ...result,
1505
+ data: parsePyneHealth(result.stdout),
1506
+ };
1507
+ });
1508
+ }
1509
+ }
1510
+
1511
+ /**
1512
+ * Parse the JSON output from `hoox agent health --json` into a structured
1513
+ * {@link AgentHealthResult}. Unknown provider shapes are skipped; the
1514
+ * function never throws on malformed input — it returns an empty providers
1515
+ * array instead.
1516
+ *
1517
+ * Expected envelope shape:
1518
+ * { "providers": [ { "name": "...", "model": "...", "status": "online",
1519
+ * "latencyMs": 45, "dailyRequests": 1234 } ] }
1520
+ */
1521
+ /**
1522
+ * Parse `hoox pyne health --json` stdout into {@link PyneHealthResult}.
1523
+ */
1524
+ function parsePyneHealth(stdout: string): PyneHealthResult {
1525
+ const timestamp = new Date().toISOString();
1526
+ const cleaned = stdout.trim();
1527
+ if (!cleaned) {
1528
+ return {
1529
+ worker: "pyne-worker",
1530
+ url: "",
1531
+ status: "down",
1532
+ error: "empty response",
1533
+ timestamp,
1534
+ };
1535
+ }
1536
+ const parsedLoose = tryParseJsonLoose(cleaned);
1537
+ if (
1538
+ parsedLoose === null ||
1539
+ typeof parsedLoose !== "object" ||
1540
+ Array.isArray(parsedLoose)
1541
+ ) {
1542
+ return {
1543
+ worker: "pyne-worker",
1544
+ url: "",
1545
+ status: "down",
1546
+ error: "invalid JSON from hoox pyne health",
1547
+ timestamp,
1548
+ };
1549
+ }
1550
+ const parsed = parsedLoose as Record<string, unknown>;
1551
+ const statusRaw = parsed.status;
1552
+ const status: PyneHealthResult["status"] =
1553
+ statusRaw === "healthy" || statusRaw === "degraded" || statusRaw === "down"
1554
+ ? statusRaw
1555
+ : "down";
1556
+ return {
1557
+ worker: typeof parsed.worker === "string" ? parsed.worker : "pyne-worker",
1558
+ url: typeof parsed.url === "string" ? parsed.url : "",
1559
+ status,
1560
+ httpStatus:
1561
+ typeof parsed.httpStatus === "number" ? parsed.httpStatus : undefined,
1562
+ latencyMs:
1563
+ typeof parsed.latencyMs === "number" ? parsed.latencyMs : undefined,
1564
+ body: parsed.body,
1565
+ error: typeof parsed.error === "string" ? parsed.error : undefined,
1566
+ timestamp,
1567
+ };
1568
+ }
1569
+
1570
+ function parseAgentHealth(stdout: string): AgentHealthResult {
1571
+ const timestamp = new Date().toISOString();
1572
+ const cleaned = stdout.trim();
1573
+ if (!cleaned) {
1574
+ return { providers: [], timestamp };
1575
+ }
1576
+
1577
+ const parsed = tryParseJsonLoose(cleaned);
1578
+ if (
1579
+ parsed === null ||
1580
+ typeof parsed !== "object" ||
1581
+ !("providers" in parsed) ||
1582
+ !Array.isArray((parsed as { providers: unknown }).providers)
1583
+ ) {
1584
+ return { providers: [], timestamp };
1585
+ }
1586
+
1587
+ const records = (parsed as { providers: unknown }).providers;
1588
+ const result: ModelHealth[] = [];
1589
+
1590
+ for (const record of records as unknown[]) {
1591
+ if (!record || typeof record !== "object") continue;
1592
+
1593
+ const r = record as Record<string, unknown>;
1594
+ const name =
1595
+ typeof r.name === "string" && r.name.length > 0 ? r.name : null;
1596
+ if (!name) continue;
1597
+
1598
+ const model = typeof r.model === "string" ? r.model : "unknown";
1599
+ const statusRaw = r.status;
1600
+ const status: ModelHealth["status"] =
1601
+ statusRaw === "online" ||
1602
+ statusRaw === "degraded" ||
1603
+ statusRaw === "offline"
1604
+ ? statusRaw
1605
+ : "offline";
1606
+ const latencyMs =
1607
+ typeof r.latencyMs === "number" && r.latencyMs >= 0 ? r.latencyMs : null;
1608
+ const dailyRequests =
1609
+ typeof r.dailyRequests === "number" && r.dailyRequests >= 0
1610
+ ? r.dailyRequests
1611
+ : null;
1612
+ const error = typeof r.error === "string" ? r.error : undefined;
1613
+
1614
+ result.push({ name, model, status, latencyMs, dailyRequests, error });
1615
+ }
1616
+
1617
+ return { providers: result, timestamp };
1618
+ }
1619
+
1620
+ /**
1621
+ * Extract the namespace ID from a `hoox config kv manifest --json` envelope
1622
+ * if it carries one. Currently the CLI's manifest envelope does not
1623
+ * include the ID, so this returns `null` for the time being. The helper
1624
+ * exists so future manifest changes can light up the namespace column
1625
+ * without touching the viewer.
1626
+ */
1627
+ function extractNamespaceId(_manifestStdout: string): string | null {
1628
+ // Reserved for future CLI versions — manifest envelope does not yet
1629
+ // surface the namespace ID. Kept as a separate function to keep the
1630
+ // test surface stable.
1631
+ return null;
1632
+ }
1633
+
1634
+ export const cliBridge = new CliBridgeImpl();
1635
+
1636
+ // ─── Re-exports for backward compatibility ─────────────────────────────────────
1637
+
1638
+ export {
1639
+ validateReadOnlySql,
1640
+ agentChatStream,
1641
+ AI_MODEL_OPTIONS,
1642
+ } from "./standalone";
1643
+ // tryParseJsonLoose + sanitizeCliArgsForLog exported above for tests
1644
+ export type {
1645
+ ExecOptions,
1646
+ KillSwitchAction,
1647
+ KillSwitchStatus,
1648
+ QueueDepthStatus,
1649
+ QueueDepth,
1650
+ DbQueryResult,
1651
+ SqlValidationResult,
1652
+ SecretMetadata,
1653
+ SecretsSnapshot,
1654
+ KvKey,
1655
+ KvKeySnapshot,
1656
+ ModelHealth,
1657
+ AgentHealthResult,
1658
+ PyneHealthResult,
1659
+ ChatMessage,
1660
+ AiModelOption,
1661
+ AgentChatStreamResult,
1662
+ } from "./types";