@hoox-sh/hoox-tui 0.2.0 → 0.3.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 (54) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4238 -1711
  3. package/package.json +2 -2
  4. package/src/app.tsx +45 -30
  5. package/src/cli-bridge-test-double.ts +48 -1
  6. package/src/components/layout/sidebar.tsx +27 -11
  7. package/src/components/layout/statusbar.test.tsx +19 -5
  8. package/src/components/layout/statusbar.tsx +34 -18
  9. package/src/components/shared/crash-screen.tsx +24 -2
  10. package/src/components/shared/error-boundary.tsx +56 -33
  11. package/src/components/ui/connection-toasts.test.ts +58 -1
  12. package/src/components/views/config-editor.test.tsx +4 -2
  13. package/src/components/views/config-editor.tsx +31 -3
  14. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  15. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  16. package/src/components/views/dashboard.test.tsx +2 -1
  17. package/src/components/views/dashboard.tsx +126 -41
  18. package/src/components/views/db-query.test.tsx +7 -0
  19. package/src/components/views/db-query.tsx +5 -4
  20. package/src/components/views/edge-topology.tsx +3 -2
  21. package/src/components/views/kv-viewer.test.tsx +28 -0
  22. package/src/components/views/kv-viewer.tsx +4 -3
  23. package/src/components/views/logs-viewer.tsx +46 -19
  24. package/src/components/views/queue-depth.test.tsx +39 -0
  25. package/src/components/views/queue-depth.tsx +3 -2
  26. package/src/components/views/secrets-viewer.test.tsx +2 -2
  27. package/src/components/views/secrets-viewer.tsx +4 -3
  28. package/src/components/views/service-manager.tsx +147 -53
  29. package/src/components/views/settings.tsx +50 -10
  30. package/src/components/views/setup-wizard.test.tsx +13 -0
  31. package/src/components/views/setup-wizard.tsx +53 -22
  32. package/src/components/views/trade-monitor.tsx +20 -13
  33. package/src/components/views/worker-detail.tsx +27 -12
  34. package/src/components/views/worker-settings.test.tsx +5 -6
  35. package/src/components/views/worker-settings.tsx +13 -11
  36. package/src/components/views/workers-overview.tsx +27 -6
  37. package/src/hooks/hooks.test.ts +303 -9
  38. package/src/hooks/index.ts +7 -1
  39. package/src/hooks/shell-overlay.ts +41 -0
  40. package/src/hooks/use-polling.ts +91 -35
  41. package/src/hooks/use-service-data.ts +21 -4
  42. package/src/main.tsx +15 -0
  43. package/src/services/cli-bridge/index.ts +28 -22
  44. package/src/services/cli-bridge/standalone.ts +29 -2
  45. package/src/services/cli-bridge.test.ts +848 -1
  46. package/src/services/clipboard.test.ts +217 -3
  47. package/src/services/dashboard-settings-loader.test.ts +50 -0
  48. package/src/services/tui-connection.test.ts +21 -0
  49. package/src/stores/service-store.test.ts +89 -3
  50. package/src/stores/ui-store.test.ts +23 -0
  51. package/src/test-setup.ts +11 -2
  52. package/src/types.ts +1 -0
  53. package/src/view-registry.test.ts +3 -1
  54. package/src/view-registry.tsx +14 -1
@@ -178,7 +178,8 @@ function killProc(
178
178
  * [ { "results": [ {...}, {...} ], "success": true,
179
179
  * "meta": { "duration": 0.123, "rows_read": 42, ... } } ]
180
180
  */
181
- function parseDbQueryResult(stdout: string): DbQueryResult {
181
+ /** Exported for unit tests — pure stdout → DbQueryResult normalizer. */
182
+ export function parseDbQueryResult(stdout: string): DbQueryResult {
182
183
  const cleaned = stdout.trim();
183
184
  if (!cleaned) {
184
185
  return {
@@ -266,7 +267,7 @@ function parseDbQueryResult(stdout: string): DbQueryResult {
266
267
  }
267
268
 
268
269
  /** Compute the {@link QueueDepthStatus} for a given depth + paused flag. */
269
- function deriveQueueDepthStatus(
270
+ export function deriveQueueDepthStatus(
270
271
  depth: number,
271
272
  paused: boolean
272
273
  ): QueueDepthStatus {
@@ -285,7 +286,8 @@ function deriveQueueDepthStatus(
285
286
  * Expected envelope shape:
286
287
  * { "queues": [ { "queue_name": "...", "producers_total_count": 2, ... } ] }
287
288
  */
288
- function parseQueueDepths(stdout: string): QueueDepth[] {
289
+ /** Exported for unit tests — pure queue-depth envelope parser. */
290
+ export function parseQueueDepths(stdout: string): QueueDepth[] {
289
291
  const timestamp = new Date().toISOString();
290
292
  const cleaned = stdout.trim();
291
293
  if (!cleaned) return [];
@@ -349,7 +351,8 @@ function parseQueueDepths(stdout: string): QueueDepth[] {
349
351
  * Values are intentionally omitted — the viewer is strictly read-only.
350
352
  * The parser never throws on malformed input.
351
353
  */
352
- function parseSecretsList(stdout: string): SecretMetadata[] {
354
+ /** Exported for unit tests — pure secrets-list envelope parser. */
355
+ export function parseSecretsList(stdout: string): SecretMetadata[] {
353
356
  const cleaned = stdout.trim();
354
357
  if (!cleaned) return [];
355
358
 
@@ -357,12 +360,8 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
357
360
  if (parsed === null) return [];
358
361
 
359
362
  // 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
- ) {
363
+ // `parsed` is non-null after the early return above.
364
+ if (typeof parsed === "object" && "worker" in parsed && "secrets" in parsed) {
366
365
  const envelope = parsed as { worker: string; secrets: unknown };
367
366
  if (Array.isArray(envelope.secrets)) {
368
367
  const result: SecretMetadata[] = [];
@@ -381,7 +380,7 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
381
380
  }
382
381
 
383
382
  // All-workers envelope: Record<string, string[]>
384
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
383
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
385
384
  const entries = Object.entries(parsed as Record<string, unknown>);
386
385
  const result: SecretMetadata[] = [];
387
386
  for (const [, value] of entries) {
@@ -408,7 +407,8 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
408
407
  * Cloudflare secrets store does not expose per-secret type metadata,
409
408
  * so we derive it from the key name suffix.
410
409
  */
411
- function inferSecretType(name: string): SecretMetadata["type"] {
410
+ /** Exported for unit tests — naming-convention secret type inference. */
411
+ export function inferSecretType(name: string): SecretMetadata["type"] {
412
412
  const lower = name.toLowerCase();
413
413
  if (lower.includes("key") || lower.includes("api")) return "api_key";
414
414
  if (lower.includes("token")) return "token";
@@ -450,7 +450,8 @@ interface WranglerKeyRecord {
450
450
  * This parser accepts both. Unknown rows are skipped; the parser never
451
451
  * throws on malformed input — it returns an empty `keys` array instead.
452
452
  */
453
- function parseKvList(
453
+ /** Exported for unit tests — pure KV list + manifest merge. */
454
+ export function parseKvList(
454
455
  stdout: string,
455
456
  manifest: Map<
456
457
  string,
@@ -464,10 +465,10 @@ function parseKvList(
464
465
  if (parsed === null) return [];
465
466
 
466
467
  let records: unknown;
468
+ // `parsed` is non-null after the early return above.
467
469
  if (Array.isArray(parsed)) {
468
470
  records = parsed;
469
471
  } else if (
470
- parsed !== null &&
471
472
  typeof parsed === "object" &&
472
473
  "keys" in parsed &&
473
474
  Array.isArray((parsed as { keys: unknown }).keys)
@@ -510,7 +511,8 @@ function parseKvList(
510
511
  * return a `Map` keyed by key name. Returns an empty map on any failure
511
512
  * — the view still functions, it just cannot flag secret keys.
512
513
  */
513
- function parseKvManifest(
514
+ /** Exported for unit tests — pure KV manifest parser. */
515
+ export function parseKvManifest(
514
516
  stdout: string
515
517
  ): Map<string, { type: "boolean" | "number" | "string"; secret: boolean }> {
516
518
  const map = new Map<
@@ -524,8 +526,8 @@ function parseKvManifest(
524
526
  if (parsed === null) return map;
525
527
 
526
528
  // Manifest shape: { namespace: "CONFIG_KV", keys: [{ key, type, default, ... }] }
529
+ // `parsed` is non-null after the early return above.
527
530
  const keys =
528
- parsed !== null &&
529
531
  typeof parsed === "object" &&
530
532
  "keys" in parsed &&
531
533
  Array.isArray((parsed as { keys: unknown }).keys)
@@ -551,8 +553,8 @@ function parseKvManifest(
551
553
  // eslint-disable-next-line no-control-regex -- intentional: matches ESC (\x1b) sequences
552
554
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
553
555
 
554
- /** Remove ANSI color escape codes from a string. */
555
- function stripAnsi(input: string): string {
556
+ /** Remove ANSI color escape codes from a string. Exported for unit tests. */
557
+ export function stripAnsi(input: string): string {
556
558
  return input.replace(ANSI_PATTERN, "");
557
559
  }
558
560
 
@@ -579,7 +581,8 @@ interface WranglerQueueRecord {
579
581
  * switches the format for set actions), so we detect the engaged/released
580
582
  * state from the message text.
581
583
  */
582
- function parseKillSwitchStatus(
584
+ /** Exported for unit tests — pure kill-switch status parser. */
585
+ export function parseKillSwitchStatus(
583
586
  action: KillSwitchAction,
584
587
  stdout: string
585
588
  ): KillSwitchStatus {
@@ -1521,7 +1524,8 @@ class CliBridgeImpl {
1521
1524
  /**
1522
1525
  * Parse `hoox pyne health --json` stdout into {@link PyneHealthResult}.
1523
1526
  */
1524
- function parsePyneHealth(stdout: string): PyneHealthResult {
1527
+ /** Exported for unit tests — pure pyne health envelope parser. */
1528
+ export function parsePyneHealth(stdout: string): PyneHealthResult {
1525
1529
  const timestamp = new Date().toISOString();
1526
1530
  const cleaned = stdout.trim();
1527
1531
  if (!cleaned) {
@@ -1567,7 +1571,8 @@ function parsePyneHealth(stdout: string): PyneHealthResult {
1567
1571
  };
1568
1572
  }
1569
1573
 
1570
- function parseAgentHealth(stdout: string): AgentHealthResult {
1574
+ /** Exported for unit tests — pure agent health envelope parser. */
1575
+ export function parseAgentHealth(stdout: string): AgentHealthResult {
1571
1576
  const timestamp = new Date().toISOString();
1572
1577
  const cleaned = stdout.trim();
1573
1578
  if (!cleaned) {
@@ -1624,7 +1629,8 @@ function parseAgentHealth(stdout: string): AgentHealthResult {
1624
1629
  * exists so future manifest changes can light up the namespace column
1625
1630
  * without touching the viewer.
1626
1631
  */
1627
- function extractNamespaceId(_manifestStdout: string): string | null {
1632
+ /** Exported for unit tests — reserved namespace-id extractor (currently always null). */
1633
+ export function extractNamespaceId(_manifestStdout: string): string | null {
1628
1634
  // Reserved for future CLI versions — manifest envelope does not yet
1629
1635
  // surface the namespace ID. Kept as a separate function to keep the
1630
1636
  // test surface stable.
@@ -197,6 +197,33 @@ export function agentChatStream(
197
197
  let completed = false;
198
198
 
199
199
  const finished = (async () => {
200
+ // Match workers/SSE: Bearer + optional CF Access service-token headers
201
+ let authHeaders: Record<string, string> = {};
202
+ let resolvedBase = apiBase;
203
+ try {
204
+ const { resolveOperatorTransportProfile, buildOperatorAuthHeaders } =
205
+ await import("@hoox-sh/hoox-shared");
206
+ const profile = resolveOperatorTransportProfile(process.env as never, {
207
+ configApiUrl: apiBase,
208
+ configApiToken: apiToken,
209
+ });
210
+ authHeaders = buildOperatorAuthHeaders(profile);
211
+ // Prefer profile base when caller passed default localhost but env has remote
212
+ if (apiBase === "http://localhost:8787" && profile.apiBase) {
213
+ resolvedBase = profile.apiBase;
214
+ } else {
215
+ resolvedBase = apiBase.replace(/\/$/, "") || profile.apiBase;
216
+ }
217
+ // Ensure Bearer from explicit apiToken if profile missed it
218
+ if (apiToken && !authHeaders.Authorization) {
219
+ authHeaders.Authorization = `Bearer ${apiToken}`;
220
+ }
221
+ } catch {
222
+ if (apiToken) {
223
+ authHeaders = { Authorization: `Bearer ${apiToken}` };
224
+ }
225
+ }
226
+
200
227
  let attempt = 0;
201
228
 
202
229
  while (
@@ -204,7 +231,7 @@ export function agentChatStream(
204
231
  attempt < SSE_MAX_RECONNECT_ATTEMPTS
205
232
  ) {
206
233
  try {
207
- const url = `${apiBase.replace(/\/$/, "")}/api/agent/chat`;
234
+ const url = `${resolvedBase.replace(/\/$/, "")}/api/agent/chat`;
208
235
  const body = JSON.stringify({
209
236
  messages: params.messages.map((m) => ({
210
237
  role: m.role,
@@ -224,7 +251,7 @@ export function agentChatStream(
224
251
  headers: {
225
252
  "Content-Type": "application/json",
226
253
  Accept: "text/event-stream",
227
- ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
254
+ ...authHeaders,
228
255
  },
229
256
  body,
230
257
  signal: abortController.signal,