@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,168 @@
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, beforeEach, afterEach } from "bun:test";
7
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ devLog,
12
+ getDevLogPath,
13
+ isDevLogEnabled,
14
+ resetDevLogForTests,
15
+ tuiDevLog,
16
+ redactSecretsInText,
17
+ redactDevLogContext,
18
+ } from "./dev-log";
19
+
20
+ describe("dev-log", () => {
21
+ const ORIGINAL_DEBUG = process.env.HOOX_DEBUG;
22
+ const ORIGINAL_TUI_DEBUG = process.env.TUI_DEBUG;
23
+ const ORIGINAL_HOOX_HOME = process.env.HOOX_HOME;
24
+ let tempHooxHome: string;
25
+
26
+ beforeEach(async () => {
27
+ resetDevLogForTests();
28
+ // Prefer HOOX_HOME override (highest priority in getHooxHome) for isolation
29
+ tempHooxHome = await mkdtemp(join(tmpdir(), "hoox-dev-log-"));
30
+ process.env.HOOX_HOME = tempHooxHome;
31
+ delete process.env.HOOX_DEBUG;
32
+ delete process.env.TUI_DEBUG;
33
+ });
34
+
35
+ afterEach(async () => {
36
+ resetDevLogForTests();
37
+ if (ORIGINAL_DEBUG === undefined) delete process.env.HOOX_DEBUG;
38
+ else process.env.HOOX_DEBUG = ORIGINAL_DEBUG;
39
+ if (ORIGINAL_TUI_DEBUG === undefined) delete process.env.TUI_DEBUG;
40
+ else process.env.TUI_DEBUG = ORIGINAL_TUI_DEBUG;
41
+ if (ORIGINAL_HOOX_HOME === undefined) delete process.env.HOOX_HOME;
42
+ else process.env.HOOX_HOME = ORIGINAL_HOOX_HOME;
43
+ await rm(tempHooxHome, { recursive: true, force: true });
44
+ });
45
+
46
+ it("is disabled by default", () => {
47
+ expect(isDevLogEnabled()).toBe(false);
48
+ });
49
+
50
+ it("enables when HOOX_DEBUG=1", () => {
51
+ process.env.HOOX_DEBUG = "1";
52
+ resetDevLogForTests();
53
+ expect(isDevLogEnabled()).toBe(true);
54
+ });
55
+
56
+ it("enables when TUI_DEBUG=true", () => {
57
+ process.env.TUI_DEBUG = "true";
58
+ resetDevLogForTests();
59
+ expect(isDevLogEnabled()).toBe(true);
60
+ });
61
+
62
+ it("no-ops when disabled (does not create log file)", async () => {
63
+ await devLog("info", "test", "should not write");
64
+ const path = getDevLogPath();
65
+ expect(path.startsWith(tempHooxHome)).toBe(true);
66
+ let exists = true;
67
+ try {
68
+ await readFile(path, "utf8");
69
+ } catch {
70
+ exists = false;
71
+ }
72
+ expect(exists).toBe(false);
73
+ });
74
+
75
+ it("appends JSON lines when enabled", async () => {
76
+ process.env.HOOX_DEBUG = "1";
77
+ resetDevLogForTests();
78
+
79
+ await tuiDevLog.info("startup", "TUI booting", {
80
+ mode: "local",
81
+ apiUrl: "http://localhost:8787",
82
+ });
83
+ await tuiDevLog.debug("api", "fetchWorkers", { path: "/workers" });
84
+
85
+ const path = getDevLogPath();
86
+ expect(path.startsWith(tempHooxHome)).toBe(true);
87
+ const raw = await readFile(path, "utf8");
88
+ const lines = raw.trim().split("\n");
89
+ expect(lines.length).toBe(2);
90
+
91
+ const first = JSON.parse(lines[0]!) as {
92
+ level: string;
93
+ scope: string;
94
+ message: string;
95
+ context: { mode: string; apiUrl: string };
96
+ };
97
+ expect(first.level).toBe("info");
98
+ expect(first.scope).toBe("startup");
99
+ expect(first.message).toBe("TUI booting");
100
+ expect(first.context.mode).toBe("local");
101
+ expect(first.context.apiUrl).toBe("http://localhost:8787");
102
+ });
103
+
104
+ it("redacts secret-looking context keys", async () => {
105
+ process.env.HOOX_DEBUG = "1";
106
+ resetDevLogForTests();
107
+
108
+ await devLog("info", "auth", "token present", {
109
+ apiToken: "super-secret-value",
110
+ path: "/health",
111
+ });
112
+
113
+ const raw = await readFile(getDevLogPath(), "utf8");
114
+ const lines = raw
115
+ .trim()
116
+ .split("\n")
117
+ .filter((l) => l.length > 0);
118
+ expect(lines.length).toBe(1);
119
+ const entry = JSON.parse(lines[0]!) as {
120
+ context: { apiToken: string; path: string };
121
+ };
122
+ expect(entry.context.apiToken).toBe("[redacted]");
123
+ expect(entry.context.path).toBe("/health");
124
+ expect(raw).not.toContain("super-secret-value");
125
+ });
126
+
127
+ it("redacts nested secret keys and message Bearer tokens", async () => {
128
+ process.env.HOOX_DEBUG = "1";
129
+ resetDevLogForTests();
130
+
131
+ await devLog(
132
+ "warn",
133
+ "http",
134
+ "Authorization: Bearer super-secret-value failed",
135
+ {
136
+ headers: { authorization: "Bearer super-secret-value" },
137
+ nested: { client_secret: "access-secret-xyz" },
138
+ path: "/workers",
139
+ }
140
+ );
141
+
142
+ const raw = await readFile(getDevLogPath(), "utf8");
143
+ expect(raw).not.toContain("super-secret-value");
144
+ expect(raw).not.toContain("access-secret-xyz");
145
+ expect(raw).toContain("[redacted]");
146
+ });
147
+
148
+ it("redactSecretsInText scrubs env-style assignments", () => {
149
+ const scrubbed = redactSecretsInText(
150
+ "HOOX_API_TOKEN=abc123 CF_ACCESS_CLIENT_SECRET=xyz Authorization: Bearer tok12345678"
151
+ );
152
+ expect(scrubbed).not.toContain("abc123");
153
+ expect(scrubbed).not.toContain("xyz");
154
+ expect(scrubbed).not.toContain("tok12345678");
155
+ expect(scrubbed).toContain("HOOX_API_TOKEN=[redacted]");
156
+ });
157
+
158
+ it("redactDevLogContext redacts nested client_secret", () => {
159
+ const out = redactDevLogContext({
160
+ ok: true,
161
+ auth: { client_secret: "s3cret", mode: "access" },
162
+ });
163
+ expect(out).toEqual({
164
+ ok: true,
165
+ auth: { client_secret: "[redacted]", mode: "access" },
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * TUI dev logger — file-backed diagnostics that never touch stdout/stderr
8
+ * (those would corrupt the OpenTUI alternate screen).
9
+ *
10
+ * Enable with any of:
11
+ * - `hoox tui --debug`
12
+ * - `HOOX_DEBUG=1` / `TUI_DEBUG=1` / `true` / `yes`
13
+ *
14
+ * Writes append-only JSON lines to `$HOME/.hoox/.tui-state/debug.log`.
15
+ */
16
+ import { appendFile, mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import {
19
+ getTuiStateDir,
20
+ resetHooxPathCacheForTests,
21
+ } from "./hoox-path-service";
22
+
23
+ export type DevLogLevel = "debug" | "info" | "warn" | "error";
24
+
25
+ export interface DevLogEntry {
26
+ ts: string;
27
+ level: DevLogLevel;
28
+ scope: string;
29
+ message: string;
30
+ context?: Record<string, unknown>;
31
+ }
32
+
33
+ const DEBUG_LOG_FILE = "debug.log";
34
+
35
+ let enabledCache: boolean | null = null;
36
+ let logPathCache: string | null = null;
37
+ let logPathCacheKey: string | null = null;
38
+ let ensureDirPromise: Promise<string> | null = null;
39
+
40
+ /** Reset caches (tests only). */
41
+ export function resetDevLogForTests(): void {
42
+ enabledCache = null;
43
+ logPathCache = null;
44
+ logPathCacheKey = null;
45
+ ensureDirPromise = null;
46
+ // Dev log path is under getTuiStateDir(); clear path cache when HOOX_HOME changes in tests.
47
+ resetHooxPathCacheForTests();
48
+ }
49
+
50
+ /**
51
+ * Whether dev logging is active for this process.
52
+ * Cached after first read so hot paths stay cheap.
53
+ */
54
+ export function isDevLogEnabled(): boolean {
55
+ if (enabledCache !== null) return enabledCache;
56
+ const v = process.env.HOOX_DEBUG ?? process.env.TUI_DEBUG ?? "";
57
+ enabledCache = v === "1" || v === "true" || v === "yes";
58
+ return enabledCache;
59
+ }
60
+
61
+ /** Absolute path to the debug log file (even if logging is disabled). */
62
+ export function getDevLogPath(): string {
63
+ const dir = getTuiStateDir();
64
+ if (logPathCache && logPathCacheKey === dir) return logPathCache;
65
+ logPathCache = join(dir, DEBUG_LOG_FILE);
66
+ logPathCacheKey = dir;
67
+ return logPathCache;
68
+ }
69
+
70
+ async function ensureLogDir(): Promise<string> {
71
+ const dir = getTuiStateDir();
72
+ // Re-bind ensure promise if the state dir moved (HOOX_HOME override in tests).
73
+ if (!ensureDirPromise || logPathCacheKey !== dir) {
74
+ ensureDirPromise = mkdir(dir, { recursive: true }).then(() => dir);
75
+ }
76
+ return ensureDirPromise;
77
+ }
78
+
79
+ /** Keys that almost always carry credentials or private material. */
80
+ const SECRET_KEY_RE =
81
+ /token|secret|password|passwd|authorization|api[_-]?key|private[_-]?key|client[_-]?secret|access[_-]?client|credential|cookie|session/i;
82
+
83
+ /**
84
+ * Redact secret-looking keys in structured context (nested objects included).
85
+ * Exported for unit tests.
86
+ */
87
+ export function redactDevLogContext(
88
+ context?: Record<string, unknown>
89
+ ): Record<string, unknown> | undefined {
90
+ if (!context) return undefined;
91
+ return redactValue(context) as Record<string, unknown>;
92
+ }
93
+
94
+ function redactValue(value: unknown): unknown {
95
+ if (value === undefined) return undefined;
96
+ if (value instanceof Error) {
97
+ return {
98
+ name: value.name,
99
+ message: redactSecretsInText(value.message),
100
+ };
101
+ }
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => redactValue(item));
104
+ }
105
+ if (value !== null && typeof value === "object") {
106
+ const out: Record<string, unknown> = {};
107
+ for (const [key, child] of Object.entries(
108
+ value as Record<string, unknown>
109
+ )) {
110
+ if (child === undefined) continue;
111
+ if (SECRET_KEY_RE.test(key)) {
112
+ out[key] =
113
+ typeof child === "string" && child.length > 0
114
+ ? "[redacted]"
115
+ : child === null
116
+ ? null
117
+ : typeof child === "object"
118
+ ? "[redacted]"
119
+ : child;
120
+ continue;
121
+ }
122
+ out[key] = redactValue(child);
123
+ }
124
+ return out;
125
+ }
126
+ if (typeof value === "string") {
127
+ return redactSecretsInText(value);
128
+ }
129
+ return value;
130
+ }
131
+
132
+ /**
133
+ * Scrub common secret patterns that leak into free-form log messages.
134
+ * Exported for unit tests.
135
+ */
136
+ export function redactSecretsInText(text: string): string {
137
+ let out = text;
138
+ // Bearer tokens in Authorization headers or prose
139
+ out = out.replace(/(Bearer\s+)[A-Za-z0-9._\-+/=]{8,}/gi, "$1[redacted]");
140
+ // Common env-style assignments
141
+ out = out.replace(
142
+ /\b(HOOX_API_TOKEN|CLOUDFLARE_API_TOKEN|CF_ACCESS_CLIENT_SECRET|CF_ACCESS_CLIENT_ID|OPERATOR_API_KEY|INTERNAL_API_KEY|INTERNAL_KEY_BINDING)\s*[=:]\s*\S+/gi,
143
+ "$1=[redacted]"
144
+ );
145
+ // Authorization: <scheme> <value>
146
+ out = out.replace(/(Authorization\s*:\s*)\S+/gi, "$1[redacted]");
147
+ return out;
148
+ }
149
+
150
+ function serializeContext(
151
+ context?: Record<string, unknown>
152
+ ): Record<string, unknown> | undefined {
153
+ return redactDevLogContext(context);
154
+ }
155
+
156
+ /**
157
+ * Append one structured log line. No-op when debug is disabled.
158
+ * Failures are swallowed — logging must never crash the TUI.
159
+ */
160
+ export async function devLog(
161
+ level: DevLogLevel,
162
+ scope: string,
163
+ message: string,
164
+ context?: Record<string, unknown>
165
+ ): Promise<void> {
166
+ if (!isDevLogEnabled()) return;
167
+
168
+ const entry: DevLogEntry = {
169
+ ts: new Date().toISOString(),
170
+ level,
171
+ scope,
172
+ message: redactSecretsInText(message),
173
+ ...(context ? { context: serializeContext(context) } : {}),
174
+ };
175
+
176
+ try {
177
+ await ensureLogDir();
178
+ await appendFile(getDevLogPath(), `${JSON.stringify(entry)}\n`, "utf8");
179
+ } catch {
180
+ // Swallow — alternate-screen UI must stay intact
181
+ }
182
+ }
183
+
184
+ /** Convenience helpers. */
185
+ export const tuiDevLog = {
186
+ debug: (scope: string, message: string, context?: Record<string, unknown>) =>
187
+ devLog("debug", scope, message, context),
188
+ info: (scope: string, message: string, context?: Record<string, unknown>) =>
189
+ devLog("info", scope, message, context),
190
+ warn: (scope: string, message: string, context?: Record<string, unknown>) =>
191
+ devLog("warn", scope, message, context),
192
+ error: (scope: string, message: string, context?: Record<string, unknown>) =>
193
+ devLog("error", scope, message, context),
194
+ };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tests for TUI Hoox Path Service
8
+ *
9
+ * Covers:
10
+ * - getTuiStateDir: returns correct path, fallback to cwd
11
+ * - ensureTuiStateDir: creates directory, returns path, fallback
12
+ * - tuiStateDirExists: checks existence of .tui-state directory
13
+ * - resolveTuiStatePath: resolves paths within .tui-state
14
+ * - resolveHooxHomePath: resolves paths within $HOME/.hoox
15
+ * - Edge cases: empty subpath, error fallback
16
+ */
17
+ import { describe, it, expect } from "bun:test";
18
+ import { unlinkSync, existsSync } from "fs";
19
+ import { join, resolve } from "path";
20
+
21
+ // ─── Import the module under test ─────────────────────────────────────────────
22
+
23
+ import {
24
+ getTuiStateDir,
25
+ ensureTuiStateDir,
26
+ tuiStateDirExists,
27
+ resolveTuiStatePath,
28
+ resolveHooxHomePath,
29
+ } from "./hoox-path-service";
30
+
31
+ // ─── Test Suite ───────────────────────────────────────────────────────────────
32
+
33
+ describe("hoox-path-service", () => {
34
+ // ── getTuiStateDir ────────────────────────────────────────────────────────
35
+
36
+ describe("getTuiStateDir", () => {
37
+ it("should return a path ending with .tui-state", () => {
38
+ const dir = getTuiStateDir();
39
+ expect(dir).toMatch(/\.tui-state$/);
40
+ });
41
+
42
+ it("should return an absolute path", () => {
43
+ const dir = getTuiStateDir();
44
+ expect(dir).toMatch(/^\/|^[A-Z]:\\/);
45
+ });
46
+
47
+ it("should be consistent across multiple calls", () => {
48
+ const dir1 = getTuiStateDir();
49
+ const dir2 = getTuiStateDir();
50
+ expect(dir1).toBe(dir2);
51
+ });
52
+
53
+ it("should contain .tui-state directory name", () => {
54
+ const dir = getTuiStateDir();
55
+ expect(dir).toContain(".tui-state");
56
+ });
57
+
58
+ it("should be under the hoox home directory", () => {
59
+ const dir = getTuiStateDir();
60
+ // Should contain .hoox/.tui-state
61
+ expect(dir).toContain(".hoox");
62
+ expect(dir).toContain(".tui-state");
63
+ });
64
+ });
65
+
66
+ // ── ensureTuiStateDir ─────────────────────────────────────────────────────
67
+
68
+ describe("ensureTuiStateDir", () => {
69
+ it("should return a path ending with .tui-state", async () => {
70
+ const dir = await ensureTuiStateDir();
71
+ expect(dir).toMatch(/\.tui-state$/);
72
+ });
73
+
74
+ it("should create the .tui-state directory if it does not exist", async () => {
75
+ // Use a known-good path (the real hoox home)
76
+ const dir = await ensureTuiStateDir();
77
+ expect(existsSync(dir)).toBe(true);
78
+ });
79
+
80
+ it("should not throw when directory already exists", async () => {
81
+ // First call creates it
82
+ await ensureTuiStateDir();
83
+ // Second call should not throw
84
+ await expect(ensureTuiStateDir()).resolves.toBeDefined();
85
+ });
86
+
87
+ it("should return a writable directory", async () => {
88
+ const dir = await ensureTuiStateDir();
89
+ // Try writing a test file
90
+ const testFile = join(dir, ".write-test");
91
+ await Bun.write(testFile, "test");
92
+ expect(existsSync(testFile)).toBe(true);
93
+ // Cleanup
94
+ unlinkSync(testFile);
95
+ });
96
+ });
97
+
98
+ // ── tuiStateDirExists ────────────────────────────────────────────────────
99
+
100
+ describe("tuiStateDirExists", () => {
101
+ it("should return true when the .tui-state directory exists", () => {
102
+ // ensureTuiStateDir should have created it in the previous test
103
+ expect(tuiStateDirExists()).toBe(true);
104
+ });
105
+ });
106
+
107
+ // ── resolveTuiStatePath ──────────────────────────────────────────────────
108
+
109
+ describe("resolveTuiStatePath", () => {
110
+ it("should resolve a simple filename within .tui-state", () => {
111
+ const path = resolveTuiStatePath("session.json");
112
+ expect(path).toMatch(/\.tui-state[/\\]session\.json$/);
113
+ });
114
+
115
+ it("should resolve nested paths within .tui-state", () => {
116
+ const path = resolveTuiStatePath("subdir/data.json");
117
+ expect(path).toMatch(/\.tui-state[/\\]subdir[/\\]data\.json$/);
118
+ });
119
+
120
+ it("should return an absolute path", () => {
121
+ const path = resolveTuiStatePath("session.json");
122
+ expect(path).toMatch(/^\/|^[A-Z]:\\/);
123
+ });
124
+
125
+ it("should be consistent with getTuiStateDir for the base path", () => {
126
+ const base = getTuiStateDir();
127
+ const sessionPath = resolveTuiStatePath("session.json");
128
+ expect(sessionPath).toBe(join(base, "session.json"));
129
+ });
130
+
131
+ it("should handle empty subpath", () => {
132
+ const path = resolveTuiStatePath("");
133
+ const base = getTuiStateDir();
134
+ expect(path).toBe(base);
135
+ });
136
+
137
+ it("should handle subpath with leading slash", () => {
138
+ const path = resolveTuiStatePath("/session.json");
139
+ const base = getTuiStateDir();
140
+ // Leading slash is stripped and treated as relative
141
+ expect(path).toBe(join(base, "session.json"));
142
+ });
143
+
144
+ it("rejects path traversal with .. segments", () => {
145
+ expect(() => resolveTuiStatePath("../escape.json")).toThrow(
146
+ /escapes TUI state directory/
147
+ );
148
+ expect(() => resolveTuiStatePath("foo/../../etc/passwd")).toThrow(
149
+ /escapes TUI state directory/
150
+ );
151
+ });
152
+
153
+ it("keeps nested relative paths under .tui-state", () => {
154
+ const path = resolveTuiStatePath("a/b/c.json");
155
+ expect(path.startsWith(getTuiStateDir())).toBe(true);
156
+ expect(path.endsWith(join("a", "b", "c.json"))).toBe(true);
157
+ });
158
+ });
159
+
160
+ // ── resolveHooxHomePath ──────────────────────────────────────────────────
161
+
162
+ describe("resolveHooxHomePath", () => {
163
+ it("should resolve a path within the hoox home directory", () => {
164
+ const path = resolveHooxHomePath("config");
165
+ expect(path).toMatch(/\.hoox[/\\]config$/);
166
+ });
167
+
168
+ it("should resolve nested paths", () => {
169
+ const path = resolveHooxHomePath("config/wrangler.jsonc");
170
+ expect(path).toMatch(/\.hoox[/\\]config[/\\]wrangler\.jsonc$/);
171
+ });
172
+
173
+ it("should return an absolute path", () => {
174
+ const path = resolveHooxHomePath("repo");
175
+ expect(path).toMatch(/^\/|^[A-Z]:\\/);
176
+ });
177
+
178
+ it("should be within hoox home", () => {
179
+ const path = resolveHooxHomePath("data");
180
+ expect(path).toContain(".hoox");
181
+ });
182
+ });
183
+ });