@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,185 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Hoox Path Service — TUI-specific path resolution for state and config.
8
+ *
9
+ * Provides utilities for:
10
+ * - Resolving the Hoox home directory ($HOME/.hoox)
11
+ * - Creating/accessing the .tui-state directory for persistent state
12
+ * - Falling back to current working directory if home location is not available
13
+ *
14
+ * Uses getHooxHome() from the shared package for cross-OS path resolution.
15
+ *
16
+ * Security: all resolved state paths are constrained under the TUI state dir
17
+ * (path traversal via `..` is rejected).
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { getTuiStateDir, ensureTuiStateDir } from "./services/hoox-path-service";
22
+ *
23
+ * const dir = await ensureTuiStateDir();
24
+ * // Returns: "/Users/alice/.hoox/.tui-state" (macOS/Linux)
25
+ * // Returns: "/home/alice/.hoox/.tui-state" (Linux)
26
+ * // Falls back to: "/current/working/dir/.tui-state" if HOME is unavailable
27
+ * ```
28
+ */
29
+ import { getHooxHome, type HooxPath } from "@hoox-sh/hoox-shared";
30
+ import { join, resolve, sep } from "path";
31
+ import { mkdir } from "fs/promises";
32
+ import { existsSync } from "fs";
33
+
34
+ /**
35
+ * Subdirectory name for TUI persistent state within the Hoox home directory.
36
+ */
37
+ const TUI_STATE_DIR = ".tui-state";
38
+
39
+ /**
40
+ * Cached absolute path for {@link getTuiStateDir}.
41
+ * Keyed by HOOX_HOME/HOME so env overrides in tests invalidate the cache.
42
+ */
43
+ let tuiStateDirCache: string | null = null;
44
+ let tuiStateDirCacheKey: string | null = null;
45
+
46
+ /** Reset path caches (tests only). */
47
+ export function resetHooxPathCacheForTests(): void {
48
+ tuiStateDirCache = null;
49
+ tuiStateDirCacheKey = null;
50
+ }
51
+
52
+ function pathEnvCacheKey(): string {
53
+ return `${process.env.HOOX_HOME ?? ""}\0${process.env.HOME ?? ""}\0${process.cwd()}`;
54
+ }
55
+
56
+ /**
57
+ * Gets the TUI state directory path: $HOME/.hoox/.tui-state
58
+ *
59
+ * Falls back to <cwd>/.tui-state if the home directory is not available.
60
+ * This fallback ensures the TUI remains functional even in environments
61
+ * where $HOME is not set (e.g., some CI runners, containers).
62
+ *
63
+ * Result is cached while HOOX_HOME/HOME/cwd are unchanged.
64
+ *
65
+ * @returns Absolute path to the TUI state directory
66
+ */
67
+ export function getTuiStateDir(): string {
68
+ const key = pathEnvCacheKey();
69
+ if (tuiStateDirCache && tuiStateDirCacheKey === key) return tuiStateDirCache;
70
+ try {
71
+ const hooxHome = getHooxHome();
72
+ tuiStateDirCache = join(hooxHome, TUI_STATE_DIR);
73
+ } catch {
74
+ // Fallback: use current working directory
75
+ tuiStateDirCache = resolve(process.cwd(), TUI_STATE_DIR);
76
+ }
77
+ tuiStateDirCacheKey = key;
78
+ return tuiStateDirCache;
79
+ }
80
+
81
+ /**
82
+ * Ensures the TUI state directory exists, creating it if necessary.
83
+ *
84
+ * This should be called during TUI initialization to make sure
85
+ * persistent state can be written. Uses mkdir with recursive option.
86
+ * Falls back to <cwd>/.tui-state if the primary path cannot be created.
87
+ *
88
+ * @returns The path to the TUI state directory that was ensured
89
+ * @throws If the directory cannot be created (even after fallback)
90
+ */
91
+ export async function ensureTuiStateDir(): Promise<string> {
92
+ const dir = getTuiStateDir();
93
+ try {
94
+ await mkdir(dir, { recursive: true });
95
+ return dir;
96
+ } catch {
97
+ // mkdir on primary path failed — fall back to current working directory
98
+ const fallback = resolve(process.cwd(), TUI_STATE_DIR);
99
+ await mkdir(fallback, { recursive: true });
100
+ tuiStateDirCache = fallback;
101
+ tuiStateDirCacheKey = pathEnvCacheKey();
102
+ return fallback;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Checks whether the TUI state directory already exists on disk.
108
+ *
109
+ * @returns true if .tui-state directory exists, false otherwise
110
+ */
111
+ export function tuiStateDirExists(): boolean {
112
+ try {
113
+ return existsSync(getTuiStateDir());
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * True when `resolved` is exactly `base` or a path under `base`.
121
+ * Uses resolved absolute paths so symlinks / `..` cannot escape.
122
+ */
123
+ function isPathInsideBase(base: string, resolved: string): boolean {
124
+ const baseResolved = resolve(base);
125
+ const target = resolve(resolved);
126
+ if (target === baseResolved) return true;
127
+ const prefix = baseResolved.endsWith(sep) ? baseResolved : baseResolved + sep;
128
+ return target.startsWith(prefix);
129
+ }
130
+
131
+ /**
132
+ * Resolves a path within the TUI state directory.
133
+ *
134
+ * Rejects path traversal (`..` segments or absolute escape). Callers that
135
+ * must never throw (storage) should catch — throw is intentional fail-closed.
136
+ *
137
+ * @param subpath - Relative path within .tui-state (e.g., "session.json", "crash.log")
138
+ * @returns Absolute path to the file within the TUI state directory
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * const sessionPath = resolveTuiStatePath("session.json");
143
+ * // Returns: "/Users/alice/.hoox/.tui-state/session.json"
144
+ * ```
145
+ */
146
+ export function resolveTuiStatePath(subpath: string): string {
147
+ // Strip leading slash to ensure subpath is treated as relative.
148
+ // On POSIX, path.join treats absolute paths as root-relative, but Bun may
149
+ // handle this differently. Normalizing to relative prevents ambiguity.
150
+ let normalized =
151
+ subpath.startsWith("/") || subpath.startsWith("\\")
152
+ ? subpath.slice(1)
153
+ : subpath;
154
+
155
+ // Normalize Windows-style separators and strip empty / `.` segments early.
156
+ const segments = normalized.split(/[/\\]+/).filter((s) => s && s !== ".");
157
+ if (segments.some((s) => s === "..")) {
158
+ throw new Error(
159
+ `Path escapes TUI state directory: refused "${subpath}" (contains "..")`
160
+ );
161
+ }
162
+ normalized = segments.join(sep);
163
+
164
+ const base = getTuiStateDir();
165
+ const resolved = resolve(base, normalized);
166
+ if (!isPathInsideBase(base, resolved)) {
167
+ throw new Error(
168
+ `Path escapes TUI state directory: refused "${subpath}" → ${resolved}`
169
+ );
170
+ }
171
+ return resolved;
172
+ }
173
+
174
+ /**
175
+ * Resolves a path within the Hoox home directory.
176
+ *
177
+ * Delegates to the shared resolveHooxPath but exposed here for TUI convenience.
178
+ *
179
+ * @param relativePath - Path relative to $HOME/.hoox
180
+ * @returns Absolute branded HooxPath
181
+ */
182
+ export function resolveHooxHomePath(relativePath: string): HooxPath {
183
+ const hooxHome = getHooxHome();
184
+ return join(hooxHome, relativePath) as HooxPath;
185
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, it, expect } from "bun:test";
7
+ import {
8
+ classifyConnectionError,
9
+ formatAuthBanner,
10
+ getApiBase,
11
+ getApiHost,
12
+ getSettingsConnectionSnapshot,
13
+ getTuiMode,
14
+ hasAccessCredentials,
15
+ hasApiToken,
16
+ isRemoteAuthReady,
17
+ resolveTuiConnectionEnv,
18
+ shouldUseCliFallback,
19
+ } from "./tui-connection";
20
+
21
+ describe("tui-connection", () => {
22
+ describe("getTuiMode", () => {
23
+ it("defaults to local", () => {
24
+ expect(getTuiMode({})).toBe("local");
25
+ });
26
+ it("reads remote", () => {
27
+ expect(getTuiMode({ HOOX_TUI_MODE: "remote" })).toBe("remote");
28
+ });
29
+ });
30
+
31
+ describe("getApiBase / getApiHost", () => {
32
+ it("defaults to localhost:8787", () => {
33
+ expect(getApiBase({})).toBe("http://localhost:8787");
34
+ expect(getApiHost(getApiBase({}))).toBe("localhost:8787");
35
+ });
36
+ it("strips trailing slashes", () => {
37
+ expect(getApiBase({ HOOX_API_URL: "https://gw.example.com///" })).toBe(
38
+ "https://gw.example.com"
39
+ );
40
+ });
41
+ });
42
+
43
+ describe("hasApiToken", () => {
44
+ it("is false when empty", () => {
45
+ expect(hasApiToken({})).toBe(false);
46
+ expect(hasApiToken({ HOOX_API_TOKEN: " " })).toBe(false);
47
+ });
48
+ it("is true when set", () => {
49
+ expect(hasApiToken({ HOOX_API_TOKEN: "secret" })).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("hasAccessCredentials / isRemoteAuthReady", () => {
54
+ it("requires both Access id and secret", () => {
55
+ expect(hasAccessCredentials({})).toBe(false);
56
+ expect(hasAccessCredentials({ CF_ACCESS_CLIENT_ID: "cid" })).toBe(false);
57
+ expect(
58
+ hasAccessCredentials({
59
+ CF_ACCESS_CLIENT_ID: "cid",
60
+ CF_ACCESS_CLIENT_SECRET: "sec",
61
+ })
62
+ ).toBe(true);
63
+ });
64
+
65
+ it("local mode is always auth-ready", () => {
66
+ expect(isRemoteAuthReady({})).toBe(true);
67
+ });
68
+
69
+ it("remote fail-closed without credentials", () => {
70
+ expect(isRemoteAuthReady({ HOOX_TUI_MODE: "remote" })).toBe(false);
71
+ });
72
+
73
+ it("remote ready with Bearer only", () => {
74
+ expect(
75
+ isRemoteAuthReady({
76
+ HOOX_TUI_MODE: "remote",
77
+ HOOX_API_TOKEN: "tok",
78
+ })
79
+ ).toBe(true);
80
+ });
81
+
82
+ it("remote ready with Access only (no Bearer)", () => {
83
+ expect(
84
+ isRemoteAuthReady({
85
+ HOOX_TUI_MODE: "remote",
86
+ CF_ACCESS_CLIENT_ID: "cid",
87
+ CF_ACCESS_CLIENT_SECRET: "sec",
88
+ })
89
+ ).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("shouldUseCliFallback", () => {
94
+ it("allows CLI only in local mode", () => {
95
+ expect(shouldUseCliFallback("local")).toBe(true);
96
+ expect(shouldUseCliFallback("remote")).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("resolveTuiConnectionEnv", () => {
101
+ it("builds a remote snapshot", () => {
102
+ const env = resolveTuiConnectionEnv({
103
+ HOOX_TUI_MODE: "remote",
104
+ HOOX_API_URL: "https://hoox.example.workers.dev",
105
+ HOOX_API_TOKEN: "tok",
106
+ });
107
+ expect(env.mode).toBe("remote");
108
+ expect(env.apiHost).toBe("hoox.example.workers.dev");
109
+ expect(env.hasToken).toBe(true);
110
+ expect(env.hasAuth).toBe(true);
111
+ expect(env.allowCliFallback).toBe(false);
112
+ });
113
+
114
+ it("remote without credentials is fail-closed (hasAuth false)", () => {
115
+ const env = resolveTuiConnectionEnv({
116
+ HOOX_TUI_MODE: "remote",
117
+ HOOX_API_URL: "https://mgmt.example.com",
118
+ });
119
+ expect(env.hasToken).toBe(false);
120
+ expect(env.hasAccessCredentials).toBe(false);
121
+ expect(env.hasAuth).toBe(false);
122
+ expect(env.allowCliFallback).toBe(false);
123
+ });
124
+
125
+ it("remote Access-only reports hasAuth without hasToken", () => {
126
+ const env = resolveTuiConnectionEnv({
127
+ HOOX_TUI_MODE: "remote",
128
+ CF_ACCESS_CLIENT_ID: "cid",
129
+ CF_ACCESS_CLIENT_SECRET: "sec",
130
+ });
131
+ expect(env.hasToken).toBe(false);
132
+ expect(env.hasAccessCredentials).toBe(true);
133
+ expect(env.hasAuth).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe("classifyConnectionError", () => {
138
+ it("detects auth failures", () => {
139
+ expect(classifyConnectionError("Authentication failed (HTTP 401)")).toBe(
140
+ "auth"
141
+ );
142
+ expect(classifyConnectionError("HTTP 403: Forbidden")).toBe("auth");
143
+ expect(classifyConnectionError("CF-Access denied")).toBe("auth");
144
+ });
145
+ it("detects rate limits", () => {
146
+ expect(classifyConnectionError("API rate limited — backing off")).toBe(
147
+ "rate-limit"
148
+ );
149
+ });
150
+ it("detects network errors", () => {
151
+ expect(
152
+ classifyConnectionError("Network request failed: ECONNREFUSED")
153
+ ).toBe("network");
154
+ expect(classifyConnectionError("socket hang up")).toBe("network");
155
+ });
156
+ it("unknown for empty", () => {
157
+ expect(classifyConnectionError(null)).toBe("unknown");
158
+ });
159
+ });
160
+
161
+ describe("formatAuthBanner", () => {
162
+ it("describes missing remote token", () => {
163
+ expect(formatAuthBanner(false, "remote")).toContain("missing");
164
+ expect(formatAuthBanner(true, "remote")).toContain("set");
165
+ });
166
+ it("mentions Access when only Access is set", () => {
167
+ expect(formatAuthBanner(false, "remote", true)).toContain("Access");
168
+ });
169
+ });
170
+
171
+ describe("getSettingsConnectionSnapshot", () => {
172
+ it("defaults to local/public with no auth", () => {
173
+ const snap = getSettingsConnectionSnapshot({});
174
+ expect(snap.mode).toBe("local");
175
+ expect(snap.transport).toBe("public");
176
+ expect(snap.authSummary).toBe("none");
177
+ expect(snap.apiHost).toBe("localhost:8787");
178
+ expect(snap.hint).toContain("hx config transport");
179
+ });
180
+
181
+ it("reports remote + Bearer without leaking token", () => {
182
+ const snap = getSettingsConnectionSnapshot({
183
+ HOOX_TUI_MODE: "remote",
184
+ HOOX_API_URL: "https://mgmt.example.com",
185
+ HOOX_API_TOKEN: "super-secret",
186
+ });
187
+ expect(snap.mode).toBe("remote");
188
+ expect(snap.apiHost).toBe("mgmt.example.com");
189
+ expect(snap.authSummary).toBe("Bearer");
190
+ expect(JSON.stringify(snap)).not.toContain("super-secret");
191
+ });
192
+
193
+ it("uses config transport fallback when env unset", () => {
194
+ const snap = getSettingsConnectionSnapshot(
195
+ {},
196
+ { transport: "access", apiUrl: "https://cfg.example.com" }
197
+ );
198
+ expect(snap.transport).toBe("access");
199
+ expect(snap.apiHost).toBe("cfg.example.com");
200
+ });
201
+
202
+ it("combines Bearer + Access in auth summary", () => {
203
+ const snap = getSettingsConnectionSnapshot({
204
+ HOOX_API_TOKEN: "tok",
205
+ CF_ACCESS_CLIENT_ID: "cid",
206
+ CF_ACCESS_CLIENT_SECRET: "sec",
207
+ });
208
+ expect(snap.authSummary).toBe("Bearer + Access");
209
+ expect(snap.transport).toBe("access");
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * TUI connection helpers — LOCAL/REMOTE mode, auth presence, and error
8
+ * classification used by the status bar, connection toasts, and startup.
9
+ *
10
+ * Pure functions (env-read wrappers included) so unit tests don't need a
11
+ * renderer. Never log raw tokens.
12
+ *
13
+ * Remote auth is **fail-closed**: REMOTE mode requires Bearer (`HOOX_API_TOKEN`)
14
+ * and/or Cloudflare Access service-token credentials. CLI fallback is never
15
+ * used in REMOTE mode.
16
+ */
17
+
18
+ import {
19
+ resolveOperatorTransportProfile,
20
+ type OperatorTransport,
21
+ type OperatorTransportEnv,
22
+ } from "@hoox-sh/hoox-shared";
23
+
24
+ export type TuiMode = "local" | "remote";
25
+
26
+ export type ConnectionErrorKind = "auth" | "rate-limit" | "network" | "unknown";
27
+
28
+ export interface TuiConnectionEnv {
29
+ mode: TuiMode;
30
+ apiUrl: string;
31
+ apiHost: string;
32
+ /** True when `HOOX_API_TOKEN` is non-empty. */
33
+ hasToken: boolean;
34
+ /** True when CF Access client id + secret are both set. */
35
+ hasAccessCredentials: boolean;
36
+ /**
37
+ * Fail-closed readiness for REMOTE: Bearer and/or Access credentials.
38
+ * LOCAL always reports true (token optional for wrangler dev).
39
+ */
40
+ hasAuth: boolean;
41
+ /** True when CLI fallback is appropriate (local only). */
42
+ allowCliFallback: boolean;
43
+ }
44
+
45
+ const DEFAULT_API_URL = "http://localhost:8787";
46
+
47
+ /** Read `HOOX_TUI_MODE` (defaults to local). */
48
+ export function getTuiMode(env: NodeJS.ProcessEnv = process.env): TuiMode {
49
+ return env.HOOX_TUI_MODE === "remote" ? "remote" : "local";
50
+ }
51
+
52
+ /** Resolved API base URL (no trailing slash). */
53
+ export function getApiBase(env: NodeJS.ProcessEnv = process.env): string {
54
+ const raw = env.HOOX_API_URL?.trim() || DEFAULT_API_URL;
55
+ return raw.replace(/\/+$/, "");
56
+ }
57
+
58
+ /** Host label for status bar / toasts. */
59
+ export function getApiHost(apiUrl: string = getApiBase()): string {
60
+ try {
61
+ return new URL(apiUrl).host || apiUrl;
62
+ } catch {
63
+ return apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "") || apiUrl;
64
+ }
65
+ }
66
+
67
+ /** Whether a non-empty API bearer token is configured. */
68
+ export function hasApiToken(env: NodeJS.ProcessEnv = process.env): boolean {
69
+ return Boolean(env.HOOX_API_TOKEN?.trim());
70
+ }
71
+
72
+ /** Whether Cloudflare Access service-token env vars are both present. */
73
+ export function hasAccessCredentials(
74
+ env: NodeJS.ProcessEnv = process.env
75
+ ): boolean {
76
+ return Boolean(
77
+ env.CF_ACCESS_CLIENT_ID?.trim() && env.CF_ACCESS_CLIENT_SECRET?.trim()
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Auth presence for fail-closed remote gateways: Bearer and/or Access pair.
83
+ * LOCAL mode always returns true (auth optional).
84
+ */
85
+ export function isRemoteAuthReady(
86
+ env: NodeJS.ProcessEnv = process.env
87
+ ): boolean {
88
+ if (getTuiMode(env) !== "remote") return true;
89
+ return hasApiToken(env) || hasAccessCredentials(env);
90
+ }
91
+
92
+ /**
93
+ * Remote HTTP is the source of truth — local CLI `check health` must not
94
+ * mark a remote session as connected when the gateway is down.
95
+ */
96
+ export function shouldUseCliFallback(mode: TuiMode): boolean {
97
+ return mode === "local";
98
+ }
99
+
100
+ /** Snapshot of mode/auth/url for startup and toasts. */
101
+ export function resolveTuiConnectionEnv(
102
+ env: NodeJS.ProcessEnv = process.env
103
+ ): TuiConnectionEnv {
104
+ const mode = getTuiMode(env);
105
+ const apiUrl = getApiBase(env);
106
+ const hasToken = hasApiToken(env);
107
+ const hasAccess = hasAccessCredentials(env);
108
+ return {
109
+ mode,
110
+ apiUrl,
111
+ apiHost: getApiHost(apiUrl),
112
+ hasToken,
113
+ hasAccessCredentials: hasAccess,
114
+ hasAuth: mode === "local" ? true : hasToken || hasAccess,
115
+ allowCliFallback: shouldUseCliFallback(mode),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Classify a connection error message (from WorkerAPIError / fetch).
121
+ * Used for auth-specific UX and toast routing.
122
+ */
123
+ export function classifyConnectionError(
124
+ message: string | null | undefined
125
+ ): ConnectionErrorKind {
126
+ if (!message) return "unknown";
127
+ const m = message.toLowerCase();
128
+ if (
129
+ m.includes("authentication failed") ||
130
+ m.includes("http 401") ||
131
+ m.includes("http 403") ||
132
+ m.includes("unauthorized") ||
133
+ m.includes("forbidden") ||
134
+ m.includes("invalid token") ||
135
+ m.includes("missing authorization") ||
136
+ m.includes("access denied") ||
137
+ m.includes("cf-access")
138
+ ) {
139
+ return "auth";
140
+ }
141
+ if (
142
+ m.includes("rate limited") ||
143
+ m.includes("http 429") ||
144
+ m.includes("429")
145
+ ) {
146
+ return "rate-limit";
147
+ }
148
+ if (
149
+ m.includes("econnrefused") ||
150
+ m.includes("enotfound") ||
151
+ m.includes("network") ||
152
+ m.includes("timeout") ||
153
+ m.includes("abort") ||
154
+ m.includes("fetch failed") ||
155
+ m.includes("connection") ||
156
+ m.includes("econnreset") ||
157
+ m.includes("socket hang up")
158
+ ) {
159
+ return "network";
160
+ }
161
+ return "unknown";
162
+ }
163
+
164
+ /**
165
+ * Human-readable auth status for CLI launch banner (never includes the token).
166
+ */
167
+ export function formatAuthBanner(
168
+ hasToken: boolean,
169
+ mode: TuiMode,
170
+ hasAccess = false
171
+ ): string {
172
+ if (hasToken && hasAccess) {
173
+ return "set (Bearer + Access)";
174
+ }
175
+ if (hasToken) return "set (Bearer HOOX_API_TOKEN)";
176
+ if (hasAccess) return "set (Cloudflare Access service token)";
177
+ if (mode === "remote") {
178
+ return "missing — remote gateway may reject requests (set HOOX_API_TOKEN or Access credentials)";
179
+ }
180
+ return "not set (optional for local wrangler dev)";
181
+ }
182
+
183
+ /** Safe one-line hint when remote auth is missing. */
184
+ export function remoteAuthMissingHint(): string {
185
+ return "Set HOOX_API_TOKEN (or pass --token), or CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET for authenticated remote API access.";
186
+ }
187
+
188
+ /** Read-only connection snapshot for Settings (never includes secrets). */
189
+ export interface SettingsConnectionSnapshot {
190
+ mode: TuiMode;
191
+ apiHost: string;
192
+ transport: OperatorTransport;
193
+ /** Human auth presence: none | Bearer | Access | Bearer + Access */
194
+ authSummary: string;
195
+ /** CLI hint for changing transport / probing security */
196
+ hint: string;
197
+ }
198
+
199
+ export interface SettingsConnectionConfigFallback {
200
+ transport?: string;
201
+ apiUrl?: string;
202
+ apiToken?: string;
203
+ }
204
+
205
+ /**
206
+ * Build a Settings-friendly connection line from env (+ optional config.json).
207
+ * Env always wins over config for URL/token/transport (mirrors operator profile).
208
+ */
209
+ export function getSettingsConnectionSnapshot(
210
+ env: NodeJS.ProcessEnv = process.env,
211
+ config: SettingsConnectionConfigFallback = {}
212
+ ): SettingsConnectionSnapshot {
213
+ const conn = resolveTuiConnectionEnv(env);
214
+ const profile = resolveOperatorTransportProfile(env as OperatorTransportEnv, {
215
+ configTransport: config.transport,
216
+ configApiUrl: config.apiUrl,
217
+ configApiToken: config.apiToken,
218
+ });
219
+
220
+ const hasBearer = Boolean(profile.bearerToken);
221
+ const hasAccess = Boolean(
222
+ profile.accessClientId && profile.accessClientSecret
223
+ );
224
+ let authSummary = "none";
225
+ if (hasBearer && hasAccess) authSummary = "Bearer + Access";
226
+ else if (hasBearer) authSummary = "Bearer";
227
+ else if (hasAccess) authSummary = "Access";
228
+
229
+ return {
230
+ mode: conn.mode,
231
+ apiHost: getApiHost(profile.apiBase),
232
+ transport: profile.transport,
233
+ authSummary,
234
+ hint: "hx config transport · hx doctor --security",
235
+ };
236
+ }