@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.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- 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";
|