@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,1321 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ /**
8
+ * Setup Wizard — Prerequisites dashboard + 6-step onboarding.
9
+ *
10
+ * Step 0: Prerequisites — system checks (wrangler, Docker, Cloudflare auth, etc.)
11
+ * Steps 1-6: API Keys, Exchanges, AI Providers, Strategies, Notifications, Deploy
12
+ *
13
+ * Runs system checks on mount via Bun.spawn and displays results with
14
+ * status indicators matching the landing page's HUD-style status display.
15
+ */
16
+ import { useState, useEffect, useCallback, useRef } from "react";
17
+ import { useKeyboard } from "@opentui/react";
18
+ import { Colors, useServiceStore } from "@hoox-sh/hoox-shared";
19
+ import { useConfigStore } from "@hoox-sh/hoox-shared";
20
+ import { useUIStore } from "@hoox-sh/hoox-shared";
21
+ import { ErrorBoundary } from "../shared/error-boundary";
22
+ import { ViewHeader } from "../shared/view-header";
23
+ import { showConfirm } from "../ui/dialog";
24
+ import type { DialogHandle } from "../ui/dialog";
25
+ import { cliBridge } from "../../services/cli-bridge";
26
+ import { resolveTuiStatePath } from "../../services/hoox-path-service";
27
+
28
+ // ─── Prerequisite Check Types ───────────────────────────────────────────────
29
+
30
+ interface PrereqCheck {
31
+ name: string;
32
+ status: "pass" | "warn" | "fail" | "checking";
33
+ version: string;
34
+ required: string;
35
+ hint?: string;
36
+ }
37
+
38
+ interface PrereqGroup {
39
+ label: string;
40
+ checks: PrereqCheck[];
41
+ }
42
+
43
+ // ─── System Check Runner ────────────────────────────────────────────────────
44
+
45
+ async function spawnVersion(
46
+ cmd: string[],
47
+ flag: string
48
+ ): Promise<string | null> {
49
+ try {
50
+ const proc = Bun.spawn([...cmd, flag], { stdout: "pipe", stderr: "pipe" });
51
+ const out = await new Response(proc.stdout).text();
52
+ const exitCode = await proc.exited;
53
+ if (exitCode !== 0) return null;
54
+ return out.trim();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function extractSemver(raw: string): string {
61
+ const m = raw.match(/(\d+\.\d+\.\d+)/);
62
+ return m ? m[1] : raw;
63
+ }
64
+
65
+ async function checkBun(): Promise<PrereqCheck> {
66
+ const raw = await spawnVersion(["bun"], "--version");
67
+ if (!raw)
68
+ return {
69
+ name: "Bun",
70
+ status: "fail",
71
+ version: "not found",
72
+ required: ">=1.2",
73
+ hint: "curl -fsSL https://bun.sh | bash",
74
+ };
75
+ const [major, minor] = raw.split(".").map(Number);
76
+ const ok = (major ?? 0) >= 1 && (minor ?? 0) >= 2;
77
+ return {
78
+ name: "Bun",
79
+ status: ok ? "pass" : "warn",
80
+ version: raw,
81
+ required: ">=1.2",
82
+ hint: ok ? undefined : "Update: curl -fsSL https://bun.sh | bash",
83
+ };
84
+ }
85
+
86
+ async function checkGit(): Promise<PrereqCheck> {
87
+ const raw = await spawnVersion(["git"], "--version");
88
+ if (!raw)
89
+ return {
90
+ name: "Git",
91
+ status: "fail",
92
+ version: "not found",
93
+ required: ">=2.40",
94
+ hint: "apt install git",
95
+ };
96
+ const m = raw.match(/(\d+\.\d+)/);
97
+ const ver = m ? m[1] : "unknown";
98
+ const [major, minor] = ver.split(".").map(Number);
99
+ const ok = (major ?? 0) >= 2 && (minor ?? 0) >= 40;
100
+ return {
101
+ name: "Git",
102
+ status: ok ? "pass" : "warn",
103
+ version: ver,
104
+ required: ">=2.40",
105
+ hint: ok ? undefined : "Update: apt install git",
106
+ };
107
+ }
108
+
109
+ async function checkWrangler(): Promise<PrereqCheck> {
110
+ const raw = await spawnVersion(["wrangler"], "--version");
111
+ if (!raw)
112
+ return {
113
+ name: "Wrangler CLI",
114
+ status: "fail",
115
+ version: "not found",
116
+ required: ">=3.88.0",
117
+ hint: "bun add -g wrangler",
118
+ };
119
+ const ver = extractSemver(raw);
120
+ const parts = ver.split(".").map(Number);
121
+ const ok = (parts[0] ?? 0) >= 3 && (parts[1] ?? 0) >= 88;
122
+ return {
123
+ name: "Wrangler CLI",
124
+ status: ok ? "pass" : "warn",
125
+ version: ver,
126
+ required: ">=3.88.0",
127
+ hint: ok ? undefined : `bun add -g wrangler@latest (${ver} < 3.88.0)`,
128
+ };
129
+ }
130
+
131
+ async function checkDocker(): Promise<PrereqCheck> {
132
+ const raw = await spawnVersion(["docker"], "--version");
133
+ if (!raw)
134
+ return {
135
+ name: "Docker",
136
+ status: "pass",
137
+ version: "not installed",
138
+ required: "optional",
139
+ hint: "Optional — used for Docker dev runtime",
140
+ };
141
+ const ver = raw.replace(/^Docker version /, "").replace(/,.*$/, "");
142
+ let composeOk = false;
143
+ try {
144
+ const cp = Bun.spawn(["docker", "compose", "version"], { stdout: "pipe" });
145
+ const co = await new Response(cp.stdout).text();
146
+ composeOk = co.trim().length > 0;
147
+ } catch {
148
+ /* ok */
149
+ }
150
+ const version = ver + (composeOk ? " (compose ✓)" : "");
151
+ return { name: "Docker", status: "pass", version, required: "optional" };
152
+ }
153
+
154
+ async function checkCfAuth(): Promise<PrereqCheck> {
155
+ const raw = await spawnVersion(["wrangler"], "whoami");
156
+ if (!raw || raw.includes("not authenticated")) {
157
+ return {
158
+ name: "Cloudflare Auth",
159
+ status: "fail",
160
+ version: "not authenticated",
161
+ required: "wrangler login",
162
+ hint: "wrangler login",
163
+ };
164
+ }
165
+ const email = raw.match(/[\w.+-]+@[\w-]+\.[\w.]+/)?.[0] ?? "authenticated";
166
+ return {
167
+ name: "Cloudflare Auth",
168
+ status: "pass",
169
+ version: email,
170
+ required: "wrangler whoami",
171
+ };
172
+ }
173
+
174
+ async function checkCfAccountId(): Promise<PrereqCheck> {
175
+ try {
176
+ const file = Bun.file("wrangler.jsonc");
177
+ const exists = await file.exists();
178
+ if (!exists) {
179
+ return {
180
+ name: "Cloudflare Account",
181
+ status: "warn",
182
+ version: "not detected",
183
+ required: "wrangler.jsonc",
184
+ hint: "Run: wrangler init or hoox init",
185
+ };
186
+ }
187
+ const text = await file.text();
188
+ const m = text.match(
189
+ /(?:account_id|accountId)\s*["']?\s*:\s*["']([^"']+)["']/
190
+ );
191
+ if (m)
192
+ return {
193
+ name: "Cloudflare Account",
194
+ status: "pass",
195
+ version: m[1].slice(0, 12) + "…",
196
+ required: "wrangler.jsonc",
197
+ };
198
+ return {
199
+ name: "Cloudflare Account",
200
+ status: "warn",
201
+ version: "not in wrangler.jsonc",
202
+ required: "account_id field",
203
+ hint: "Add account_id to wrangler.jsonc",
204
+ };
205
+ } catch {
206
+ return {
207
+ name: "Cloudflare Account",
208
+ status: "fail",
209
+ version: "read error",
210
+ required: "wrangler.jsonc",
211
+ };
212
+ }
213
+ }
214
+
215
+ async function checkRepository(): Promise<PrereqCheck> {
216
+ try {
217
+ const wExists = await Bun.file("wrangler.jsonc").exists();
218
+ const envExists =
219
+ (await Bun.file(".env.local").exists()) ||
220
+ (await Bun.file(".env.example").exists());
221
+ if (!wExists)
222
+ return {
223
+ name: "Repository",
224
+ status: "fail",
225
+ version: "wrangler.jsonc missing",
226
+ required: "valid",
227
+ hint: "hoox init",
228
+ };
229
+ if (!envExists)
230
+ return {
231
+ name: "Repository",
232
+ status: "warn",
233
+ version: "no .env found",
234
+ required: "valid",
235
+ hint: "Copy .env.example to .env.local",
236
+ };
237
+ return {
238
+ name: "Repository",
239
+ status: "pass",
240
+ version: "OK",
241
+ required: "valid",
242
+ };
243
+ } catch {
244
+ return {
245
+ name: "Repository",
246
+ status: "fail",
247
+ version: "check failed",
248
+ required: "valid",
249
+ };
250
+ }
251
+ }
252
+
253
+ // ─── Constants ──────────────────────────────────────────────────────────────
254
+
255
+ const SETUP_STEPS = [
256
+ "PREREQUISITES",
257
+ "API KEYS",
258
+ "EXCHANGES",
259
+ "AI PROVIDERS",
260
+ "STRATEGIES",
261
+ "NOTIFICATIONS",
262
+ "DEPLOY",
263
+ ] as const;
264
+
265
+ const TOTAL_SETUP_STEPS = SETUP_STEPS.length;
266
+
267
+ /** Exchanges supported by the wizard */
268
+ const EXCHANGES = ["Binance", "Bybit", "MEXC"] as const;
269
+
270
+ /** Strategy types available */
271
+ const STRATEGIES = ["grid", "macd", "scalping"] as const;
272
+
273
+ type StrategyType = (typeof STRATEGIES)[number];
274
+
275
+ // ─── Form Data Shape ────────────────────────────────────────────────────────
276
+
277
+ interface ApiKeyEntry {
278
+ key: string;
279
+ secret: string;
280
+ }
281
+
282
+ interface WizardFormData {
283
+ apiKeys: Record<string, ApiKeyEntry>;
284
+ exchanges: Record<string, boolean>;
285
+ ai: { providerUrl: string; apiKey: string; model: string };
286
+ strategy: { type: StrategyType; params: Record<string, string> };
287
+ notifications: {
288
+ email: { enabled: boolean; address: string };
289
+ telegram: { enabled: boolean; botToken: string; chatId: string };
290
+ discord: { enabled: boolean; webhookUrl: string };
291
+ };
292
+ }
293
+
294
+ /** Validation result: null = unchecked, true = valid, false = invalid */
295
+ type ValidationState = null | boolean;
296
+
297
+ // ─── Default Form Data ──────────────────────────────────────────────────────
298
+
299
+ const defaultFormData = (): WizardFormData => ({
300
+ apiKeys: {
301
+ binance: { key: "", secret: "" },
302
+ bybit: { key: "", secret: "" },
303
+ mexc: { key: "", secret: "" },
304
+ },
305
+ exchanges: { binance: false, bybit: false, mexc: false },
306
+ ai: { providerUrl: "", apiKey: "", model: "default" },
307
+ strategy: { type: "grid", params: {} },
308
+ notifications: {
309
+ email: { enabled: false, address: "" },
310
+ telegram: { enabled: false, botToken: "", chatId: "" },
311
+ discord: { enabled: false, webhookUrl: "" },
312
+ },
313
+ });
314
+
315
+ /**
316
+ * Session persistence file path for wizard state.
317
+ * Survives TUI close/restart so users can resume where they left off.
318
+ * Uses the shared getHooxHome() utility for cross-OS home directory resolution
319
+ * and stores wizard sessions in $HOME/.hoox/.tui-state/.
320
+ */
321
+ const WIZARD_SESSION_PATH = resolveTuiStatePath(".wizard-session.json");
322
+
323
+ // ─── Validation Helpers ─────────────────────────────────────────────────────
324
+
325
+ /** Basic format check for exchange API keys (non-empty, no whitespace at ends) */
326
+ export function validateApiKey(value: string): boolean {
327
+ return value.trim().length >= 16 && value === value.trim();
328
+ }
329
+
330
+ /** Basic email format check */
331
+ export function validateEmail(value: string): boolean {
332
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
333
+ }
334
+
335
+ /** Basic URL format check */
336
+ export function validateUrl(value: string): boolean {
337
+ return /^https?:\/\/.+/.test(value);
338
+ }
339
+
340
+ /** Mask a secret string: show first 4 and last 4 chars, fill middle with •••• */
341
+ export function maskSecret(value: string): string {
342
+ if (value.length <= 8) return "•".repeat(value.length || 4);
343
+ return value.slice(0, 4) + "••••" + value.slice(-4);
344
+ }
345
+
346
+ // ─── Session Persistence Helpers ─────────────────────────────────────────────
347
+
348
+ /**
349
+ * Strip secrets / tokens from wizard form data before any disk write.
350
+ * Session resume keeps step + non-secret preferences only.
351
+ *
352
+ * Cleared fields: exchange API keys/secrets, AI apiKey, Telegram bot token,
353
+ * Discord webhook URL. Non-secret toggles, emails, chat IDs, and strategy
354
+ * choices are preserved.
355
+ */
356
+ export function redactWizardSecrets(data: WizardFormData): WizardFormData {
357
+ const next = structuredClone(data);
358
+ for (const exchange of Object.keys(next.apiKeys)) {
359
+ next.apiKeys[exchange] = { key: "", secret: "" };
360
+ }
361
+ next.ai = { ...next.ai, apiKey: "" };
362
+ next.notifications = {
363
+ email: { ...next.notifications.email },
364
+ telegram: {
365
+ ...next.notifications.telegram,
366
+ botToken: "",
367
+ },
368
+ discord: {
369
+ ...next.notifications.discord,
370
+ webhookUrl: "",
371
+ },
372
+ };
373
+ return next;
374
+ }
375
+
376
+ /**
377
+ * Persist wizard step + form data + validation state to disk.
378
+ * Fire-and-forget; failures are non-fatal.
379
+ * Secrets are never written to disk (see {@link redactWizardSecrets}).
380
+ */
381
+ function persistSession(
382
+ step: number,
383
+ data: WizardFormData,
384
+ validation: Record<string, ValidationState>
385
+ ) {
386
+ Bun.write(
387
+ WIZARD_SESSION_PATH,
388
+ JSON.stringify({
389
+ step,
390
+ data: redactWizardSecrets(data),
391
+ validation,
392
+ })
393
+ ).catch(() => {});
394
+ }
395
+
396
+ /** Clear saved wizard session (called after successful deploy). */
397
+ function clearSessionFile() {
398
+ Bun.write(WIZARD_SESSION_PATH, "").catch(() => {});
399
+ }
400
+
401
+ // ─── Component Props ────────────────────────────────────────────────────────
402
+
403
+ export interface SetupWizardProps {
404
+ /** Dialog handle for deploy confirmation (injected by parent or default) */
405
+ dialog?: DialogHandle;
406
+ }
407
+
408
+ // ─── Component ──────────────────────────────────────────────────────────────
409
+
410
+ export function SetupWizard({ dialog }: SetupWizardProps) {
411
+ const [step, setStep] = useState(0);
412
+ const [data, setData] = useState<WizardFormData>(defaultFormData);
413
+ const [validation, setValidation] = useState<Record<string, ValidationState>>(
414
+ {}
415
+ );
416
+ const [prereqs, setPrereqs] = useState<PrereqGroup[]>([]);
417
+ const [prereqsRunning, setPrereqsRunning] = useState(true);
418
+ const [cfAccountId, setCfAccountId] = useState("");
419
+ /** Masked CF token display only — cleartext never retained in React state. */
420
+ const [cfApiTokenMasked, setCfApiTokenMasked] = useState<string | null>(null);
421
+ const [deploying, setDeploying] = useState(false);
422
+ const [deployLog, setDeployLog] = useState("");
423
+ /** Inline validation / navigation feedback (non-secret). */
424
+ const [stepHint, setStepHint] = useState<string | null>(null);
425
+
426
+ const updateConfig = useConfigStore((s) => s.updateConfig);
427
+ const setView = useUIStore((s) => s.setView);
428
+ const activeView = useUIStore((s) => s.activeView);
429
+ const isActive = activeView === "setup-wizard";
430
+ const addAlert = useServiceStore((s) => s.addAlert);
431
+ const loadedRef = useRef(false);
432
+
433
+ // ── Run system checks on mount ──────────────────────────────────────────
434
+ const runChecks = useCallback(async () => {
435
+ setPrereqsRunning(true);
436
+ setPrereqs([
437
+ { label: "RUNTIME", checks: [await checkBun(), await checkGit()] },
438
+ { label: "TOOLS", checks: [await checkWrangler(), await checkDocker()] },
439
+ {
440
+ label: "CLOUDFLARE",
441
+ checks: [await checkCfAuth(), await checkCfAccountId()],
442
+ },
443
+ { label: "REPOSITORY", checks: [await checkRepository()] },
444
+ ]);
445
+ setPrereqsRunning(false);
446
+ }, []);
447
+
448
+ useEffect(() => {
449
+ runChecks();
450
+ }, [runChecks]);
451
+
452
+ // ── Detect Cloudflare account ID from wrangler.jsonc ────────────────────
453
+ useEffect(() => {
454
+ (async () => {
455
+ try {
456
+ const file = Bun.file("wrangler.jsonc");
457
+ if (await file.exists()) {
458
+ const text = await file.text();
459
+ const m = text.match(
460
+ /(?:account_id|accountId)\s*["']?\s*:\s*["']([^"']+)["']/
461
+ );
462
+ if (m) setCfAccountId(m[1]);
463
+ }
464
+ const cfgFile = Bun.file(
465
+ `${process.env.HOME ?? "~"}/.hoox/config.json`
466
+ );
467
+ if (await cfgFile.exists()) {
468
+ const cfg = JSON.parse(await cfgFile.text()) as {
469
+ apiToken?: string;
470
+ };
471
+ // Mask immediately; discard cleartext so it never lives in state.
472
+ if (typeof cfg.apiToken === "string" && cfg.apiToken.length > 0) {
473
+ setCfApiTokenMasked(maskSecret(cfg.apiToken));
474
+ }
475
+ }
476
+ } catch {
477
+ /* non-fatal */
478
+ }
479
+ })();
480
+ }, []);
481
+
482
+ // ── Restore wizard session from disk on mount ───────────────────────────
483
+ useEffect(() => {
484
+ (async () => {
485
+ try {
486
+ const f = Bun.file(WIZARD_SESSION_PATH);
487
+ if (!(await f.exists())) return;
488
+ const raw = await f.text();
489
+ if (!raw) return;
490
+ const session = JSON.parse(raw);
491
+ if (typeof session.step !== "number" || !session.data) return;
492
+ // Merge saved data with defaults to handle any schema additions
493
+ const merged: WizardFormData = {
494
+ ...defaultFormData(),
495
+ ...session.data,
496
+ apiKeys: { ...defaultFormData().apiKeys, ...session.data.apiKeys },
497
+ exchanges: {
498
+ ...defaultFormData().exchanges,
499
+ ...session.data.exchanges,
500
+ },
501
+ ai: { ...defaultFormData().ai, ...session.data.ai },
502
+ strategy: { ...defaultFormData().strategy, ...session.data.strategy },
503
+ notifications: {
504
+ ...defaultFormData().notifications,
505
+ ...session.data.notifications,
506
+ },
507
+ };
508
+ setStep(session.step);
509
+ // Drop any secrets that older sessions may have written cleartext.
510
+ const safe = redactWizardSecrets(merged);
511
+ setData(safe);
512
+ if (session.validation) setValidation(session.validation);
513
+ // Rewrite session without secrets so disk is cleaned on resume.
514
+ persistSession(session.step, safe, session.validation ?? {});
515
+ } catch {
516
+ /* no saved session or corrupt file */
517
+ }
518
+ loadedRef.current = true;
519
+ })();
520
+ }, []);
521
+
522
+ /**
523
+ * Update a field in the form data (deep path via dot notation, e.g. "apiKeys.binance.key")
524
+ * Resets any existing validation for that field.
525
+ */
526
+ const updateField = (path: string, value: string | boolean) => {
527
+ setData((prev) => {
528
+ const next = structuredClone(prev);
529
+ const keys = path.split(".");
530
+ let obj: Record<string, unknown> = next as unknown as Record<
531
+ string,
532
+ unknown
533
+ >;
534
+ for (let i = 0; i < keys.length - 1; i++) {
535
+ const key = keys[i];
536
+ if (key === "__proto__" || key === "constructor" || key === "prototype")
537
+ return prev;
538
+ obj = obj[key] as Record<string, unknown>;
539
+ }
540
+ const lastKey = keys[keys.length - 1];
541
+ if (
542
+ lastKey === "__proto__" ||
543
+ lastKey === "constructor" ||
544
+ lastKey === "prototype"
545
+ )
546
+ return prev;
547
+ obj[lastKey] = value;
548
+ return next;
549
+ });
550
+ setValidation((prev) => {
551
+ const next = { ...prev };
552
+ delete next[path];
553
+ return next;
554
+ });
555
+ };
556
+
557
+ /** Validate the current step's fields */
558
+ const validateStep = (): boolean => {
559
+ const results: Record<string, ValidationState> = {};
560
+ let allValid = true;
561
+
562
+ if (step === 1) {
563
+ for (const exchange of EXCHANGES) {
564
+ const e = exchange.toLowerCase();
565
+ const keyPath = `apiKeys.${e}.key`;
566
+ const secretPath = `apiKeys.${e}.secret`;
567
+ const key = data.apiKeys[e].key;
568
+ const secret = data.apiKeys[e].secret;
569
+ if (key) {
570
+ results[keyPath] = validateApiKey(key);
571
+ if (!results[keyPath]) allValid = false;
572
+ }
573
+ if (secret) {
574
+ results[secretPath] = validateApiKey(secret);
575
+ if (!results[secretPath]) allValid = false;
576
+ }
577
+ }
578
+ } else if (step === 3) {
579
+ // AI providers: if a URL is provided it must be valid; key optional but format-checked when set.
580
+ if (data.ai.providerUrl) {
581
+ results["ai.providerUrl"] = validateUrl(data.ai.providerUrl);
582
+ if (!results["ai.providerUrl"]) allValid = false;
583
+ }
584
+ if (data.ai.apiKey) {
585
+ results["ai.apiKey"] = validateApiKey(data.ai.apiKey);
586
+ if (!results["ai.apiKey"]) allValid = false;
587
+ }
588
+ } else if (step === 5) {
589
+ if (data.notifications.email.enabled) {
590
+ results["notifications.email.address"] = data.notifications.email
591
+ .address
592
+ ? validateEmail(data.notifications.email.address)
593
+ : false;
594
+ if (!results["notifications.email.address"]) allValid = false;
595
+ }
596
+ if (data.notifications.telegram.enabled) {
597
+ results["notifications.telegram.botToken"] = data.notifications.telegram
598
+ .botToken
599
+ ? data.notifications.telegram.botToken.trim().length > 0
600
+ : false;
601
+ if (!results["notifications.telegram.botToken"]) allValid = false;
602
+ }
603
+ if (data.notifications.discord.enabled) {
604
+ results["notifications.discord.webhookUrl"] = data.notifications.discord
605
+ .webhookUrl
606
+ ? validateUrl(data.notifications.discord.webhookUrl)
607
+ : false;
608
+ if (!results["notifications.discord.webhookUrl"]) allValid = false;
609
+ }
610
+ }
611
+
612
+ setValidation((prev) => ({ ...prev, ...results }));
613
+ return allValid;
614
+ };
615
+
616
+ /** Advance to next step */
617
+ const handleNext = () => {
618
+ if (step === 0) {
619
+ if (!prereqsRunning) {
620
+ const next = 1;
621
+ setStep(next);
622
+ setStepHint(null);
623
+ persistSession(next, data, validation);
624
+ } else {
625
+ setStepHint("Wait for prerequisite checks to finish");
626
+ }
627
+ return;
628
+ }
629
+ if (!validateStep()) {
630
+ setStepHint("Fix validation errors before continuing");
631
+ return;
632
+ }
633
+ if (step < TOTAL_SETUP_STEPS - 1) {
634
+ const next = step + 1;
635
+ setStep(next);
636
+ setStepHint(null);
637
+ persistSession(next, data, validation);
638
+ }
639
+ };
640
+
641
+ /** Go back to previous step */
642
+ const handleBack = () => {
643
+ if (step > 0) {
644
+ const prev = step - 1;
645
+ setStep(prev);
646
+ setStepHint(null);
647
+ persistSession(prev, data, validation);
648
+ }
649
+ };
650
+
651
+ /** Skip current step (optional steps only) */
652
+ const handleSkip = () => {
653
+ if (step < TOTAL_SETUP_STEPS - 1) {
654
+ if (step === 2 || step === 4) {
655
+ const next = step + 1;
656
+ setStep(next);
657
+ setStepHint(null);
658
+ persistSession(next, data, validation);
659
+ }
660
+ }
661
+ };
662
+
663
+ /** Deploy: write config, run hoox deploy all, navigate on success */
664
+ const handleDeploy = async () => {
665
+ try {
666
+ const activeExchanges = Object.entries(data.exchanges)
667
+ .filter(([, v]) => v)
668
+ .map(([k]) => k);
669
+
670
+ updateConfig({ activeExchanges });
671
+
672
+ // Fail closed: never deploy without an interactive confirm surface.
673
+ if (!dialog) {
674
+ addAlert({
675
+ id: `deploy-noconfirm-${Date.now()}`,
676
+ type: "deploy",
677
+ severity: "warning",
678
+ message: "Deploy blocked: confirmation dialog unavailable",
679
+ timestamp: Date.now(),
680
+ acknowledged: false,
681
+ source: "SetupWizard",
682
+ });
683
+ return;
684
+ }
685
+ const confirmed = await showConfirm(dialog, {
686
+ title: "Deploy Configuration",
687
+ message: "Save setup and apply configuration to Cloudflare?",
688
+ confirmLabel: "Deploy",
689
+ cancelLabel: "Cancel",
690
+ });
691
+ if (!confirmed) return;
692
+
693
+ setDeploying(true);
694
+ setDeployLog("");
695
+
696
+ const result = await cliBridge.deployAll((chunk) => {
697
+ setDeployLog((prev) => prev + chunk);
698
+ });
699
+
700
+ if (result.success) {
701
+ addAlert({
702
+ id: `deploy-${Date.now()}`,
703
+ type: "deploy",
704
+ severity: "info",
705
+ message: "Deployment completed successfully",
706
+ timestamp: Date.now(),
707
+ acknowledged: false,
708
+ source: "SetupWizard",
709
+ });
710
+ clearSessionFile();
711
+ setView("dashboard");
712
+ } else {
713
+ setDeployLog(
714
+ (prev) =>
715
+ prev + `\nDeploy failed: ${result.stderr || "Unknown error"}\n`
716
+ );
717
+ }
718
+ } catch (err) {
719
+ setDeployLog(
720
+ (prev) => prev + `\nDeploy error: ${(err as Error).message}\n`
721
+ );
722
+ } finally {
723
+ setDeploying(false);
724
+ }
725
+ };
726
+
727
+ // ── Keyboard navigation (only when Setup Wizard is active) ──────────────
728
+ useKeyboard((key) => {
729
+ if (!isActive) return;
730
+ if (key.name === "right" || (key.ctrl && key.name === "n")) handleNext();
731
+ if (key.name === "left" || (key.ctrl && key.name === "p")) handleBack();
732
+ if (key.name === "escape") handleBack();
733
+ if (key.name === "tab" && key.shift) handleBack();
734
+ if (key.name === "r" && step === 0) runChecks();
735
+ });
736
+
737
+ // ── Validation indicator for a field ─────────────────────────────────────
738
+ const ValidationIcon = ({ fieldPath }: { fieldPath: string }) => {
739
+ const state = validation[fieldPath];
740
+ if (state === null) return null;
741
+ return (
742
+ <text fg={state ? Colors.success : Colors.error}>
743
+ {state ? " ✓" : " ✗"}
744
+ </text>
745
+ );
746
+ };
747
+
748
+ // ── Masked input for secrets ────────────────────────────────────────────
749
+ const SecretField = ({
750
+ label,
751
+ value,
752
+ path,
753
+ width,
754
+ }: {
755
+ label: string;
756
+ value: string;
757
+ path: string;
758
+ width?: number;
759
+ }) => (
760
+ <box flexDirection="row" gap={1}>
761
+ <text fg={Colors.muted} width={16}>
762
+ {label}
763
+ </text>
764
+ <text fg={value ? Colors.muted : Colors.dim} width={width ?? 24}>
765
+ {value ? maskSecret(value) : "(not set)"}
766
+ </text>
767
+ <ValidationIcon fieldPath={path} />
768
+ </box>
769
+ );
770
+
771
+ // ── Checkbox toggle ─────────────────────────────────────────────────────
772
+ const Checkbox = ({
773
+ label,
774
+ checked,
775
+ path,
776
+ }: {
777
+ label: string;
778
+ checked: boolean;
779
+ path: string;
780
+ }) => (
781
+ <box flexDirection="row" gap={1}>
782
+ <text
783
+ fg={checked ? Colors.accent : Colors.muted}
784
+ onMouseUp={() => updateField(path, !checked)}
785
+ >
786
+ {checked ? "[x]" : "[ ]"}
787
+ </text>
788
+ <text fg={Colors.foreground}>{label}</text>
789
+ </box>
790
+ );
791
+
792
+ // ── Status Icon helper ──────────────────────────────────────────────────
793
+ const StatusIcon = ({ status }: { status: PrereqCheck["status"] }) => {
794
+ if (status === "checking") return <text fg={Colors.muted}>⋯</text>;
795
+ if (status === "pass") return <text fg={Colors.success}>█</text>;
796
+ if (status === "warn") return <text fg={Colors.warning}>▌</text>;
797
+ return (
798
+ <text fg={Colors.error} dim>
799
+ ░
800
+ </text>
801
+ );
802
+ };
803
+
804
+ // ── Step Content: PREREQUISITES (step 0) ────────────────────────────────
805
+ const StepPrerequisites = () => {
806
+ const allPassed = prereqs.every((g) =>
807
+ g.checks.every((c) => c.status === "pass")
808
+ );
809
+ return (
810
+ <box flexDirection="column" gap={1}>
811
+ {/* System checks grid */}
812
+ {prereqs.map((group) => (
813
+ <box flexDirection="column" gap={0}>
814
+ <text bold fg={Colors["muted-foreground"]} dim>
815
+ ┌ {group.label} ┐
816
+ </text>
817
+ {group.checks.map((c) => (
818
+ <box flexDirection="row" gap={2} paddingLeft={2}>
819
+ <StatusIcon status={c.status} />
820
+ <text fg={Colors.foreground} width={20}>
821
+ {c.name}
822
+ </text>
823
+ <text
824
+ fg={
825
+ c.status === "pass"
826
+ ? Colors.success
827
+ : c.status === "warn"
828
+ ? Colors.warning
829
+ : Colors.error
830
+ }
831
+ >
832
+ {c.status === "checking"
833
+ ? "CHECKING"
834
+ : c.status === "pass"
835
+ ? "PASS"
836
+ : c.status === "warn"
837
+ ? "WARN"
838
+ : "FAIL"}
839
+ </text>
840
+ <text fg={Colors.muted}>{c.version}</text>
841
+ {c.hint && (
842
+ <text fg={Colors.dim} dim>
843
+ — {c.hint}
844
+ </text>
845
+ )}
846
+ </box>
847
+ ))}
848
+ </box>
849
+ ))}
850
+
851
+ {/* Cloudflare config card */}
852
+ <box flexDirection="column" gap={0} paddingTop={1}>
853
+ <text bold fg={Colors["muted-foreground"]} dim>
854
+ ┌ CLOUDFLARE CONFIG ┐
855
+ </text>
856
+ <box flexDirection="column" gap={0} paddingLeft={2}>
857
+ <box flexDirection="row" gap={2}>
858
+ <text fg={Colors.muted} width={14}>
859
+ Account ID:
860
+ </text>
861
+ <text fg={cfAccountId ? Colors.success : Colors.warning}>
862
+ {cfAccountId || "(auto-detect)"}
863
+ </text>
864
+ {cfAccountId && <text fg={Colors.success}> ✓</text>}
865
+ </box>
866
+ <box flexDirection="row" gap={2}>
867
+ <text fg={Colors.muted} width={14}>
868
+ API Token:
869
+ </text>
870
+ <text fg={cfApiTokenMasked ? Colors.success : Colors.dim}>
871
+ {cfApiTokenMasked ?? "(not set)"}
872
+ </text>
873
+ {cfApiTokenMasked && <text fg={Colors.success}> ✓</text>}
874
+ </box>
875
+ </box>
876
+ </box>
877
+
878
+ {/* Overall status */}
879
+ <box paddingTop={1} paddingLeft={2}>
880
+ <text fg={allPassed ? Colors.success : Colors.warning} bold>
881
+ {prereqsRunning
882
+ ? "RUNNING CHECKS…"
883
+ : allPassed
884
+ ? "ALL CHECKS PASSED"
885
+ : "SOME CHECKS NEED ATTENTION"}
886
+ </text>
887
+ </box>
888
+ </box>
889
+ );
890
+ };
891
+
892
+ // ── Step Content Renderers (shifted: old step N is now step N+1) ────────
893
+
894
+ /** Step 1: API Keys */
895
+ const StepApiKeys = () => (
896
+ <box flexDirection="column" gap={0}>
897
+ <text bold fg={Colors.accent}>
898
+ Exchange API Keys
899
+ </text>
900
+ <text dim fg={Colors.muted}>
901
+ Enter your exchange API credentials. Secrets are stored encrypted.
902
+ </text>
903
+ <box flexDirection="column" gap={1} paddingTop={1}>
904
+ {EXCHANGES.map((name) => {
905
+ const e = name.toLowerCase();
906
+ return (
907
+ <box flexDirection="column" gap={0}>
908
+ <text bold fg={Colors.foreground}>
909
+ {name}
910
+ </text>
911
+ <SecretField
912
+ label=" API Key"
913
+ value={data.apiKeys[e].key}
914
+ path={`apiKeys.${e}.key`}
915
+ />
916
+ <SecretField
917
+ label=" API Secret"
918
+ value={data.apiKeys[e].secret}
919
+ path={`apiKeys.${e}.secret`}
920
+ />
921
+ </box>
922
+ );
923
+ })}
924
+ </box>
925
+ </box>
926
+ );
927
+
928
+ /** Step 2: Exchanges */
929
+ const StepExchanges = () => (
930
+ <box flexDirection="column" gap={0}>
931
+ <text bold fg={Colors.accent}>
932
+ Active Exchanges
933
+ </text>
934
+ <text dim fg={Colors.muted}>
935
+ Enable the exchanges you want to trade on.
936
+ </text>
937
+ <box flexDirection="column" gap={0} paddingTop={1}>
938
+ {EXCHANGES.map((name) => (
939
+ <Checkbox
940
+ label={name}
941
+ checked={data.exchanges[name.toLowerCase()]}
942
+ path={`exchanges.${name.toLowerCase()}`}
943
+ />
944
+ ))}
945
+ </box>
946
+ </box>
947
+ );
948
+
949
+ /** Step 3: AI Providers */
950
+ const StepAiProviders = () => (
951
+ <box flexDirection="column" gap={0}>
952
+ <text bold fg={Colors.accent}>
953
+ AI Provider
954
+ </text>
955
+ <text dim fg={Colors.muted}>
956
+ Configure the AI endpoint for strategy generation.
957
+ </text>
958
+ <box flexDirection="column" gap={1} paddingTop={1}>
959
+ <box flexDirection="row" gap={1}>
960
+ <text fg={Colors.muted} width={12}>
961
+ Provider URL
962
+ </text>
963
+ <text fg={data.ai.providerUrl ? Colors.foreground : Colors.dim}>
964
+ {data.ai.providerUrl || "(not set)"}
965
+ </text>
966
+ </box>
967
+ <SecretField label="API Key" value={data.ai.apiKey} path="ai.apiKey" />
968
+ <box flexDirection="row" gap={1}>
969
+ <text fg={Colors.muted} width={12}>
970
+ Model
971
+ </text>
972
+ <text fg={Colors.foreground}>{data.ai.model}</text>
973
+ </box>
974
+ </box>
975
+ </box>
976
+ );
977
+
978
+ /** Step 4: Strategies */
979
+ const StepStrategies = () => (
980
+ <box flexDirection="column" gap={0}>
981
+ <text bold fg={Colors.accent}>
982
+ Trading Strategy
983
+ </text>
984
+ <text dim fg={Colors.muted}>
985
+ Select your primary trading strategy.
986
+ </text>
987
+ <box flexDirection="column" gap={0} paddingTop={1}>
988
+ {STRATEGIES.map((s) => (
989
+ <box flexDirection="row" gap={1}>
990
+ <text
991
+ fg={data.strategy.type === s ? Colors.accent : Colors.muted}
992
+ onMouseUp={() => {
993
+ setData((prev) => ({
994
+ ...prev,
995
+ strategy: { ...prev.strategy, type: s },
996
+ }));
997
+ }}
998
+ >
999
+ {data.strategy.type === s ? "(•) " : "( ) "}
1000
+ </text>
1001
+ <text fg={Colors.foreground}>
1002
+ {s.charAt(0).toUpperCase() + s.slice(1)}
1003
+ </text>
1004
+ </box>
1005
+ ))}
1006
+ </box>
1007
+ </box>
1008
+ );
1009
+
1010
+ /** Step 5: Notifications */
1011
+ const StepNotifications = () => (
1012
+ <box flexDirection="column" gap={0}>
1013
+ <text bold fg={Colors.accent}>
1014
+ Notifications
1015
+ </text>
1016
+ <text dim fg={Colors.muted}>
1017
+ Configure alert channels for trade events and system status.
1018
+ </text>
1019
+ <box flexDirection="column" gap={1} paddingTop={1}>
1020
+ <box flexDirection="column" gap={0}>
1021
+ <Checkbox
1022
+ label="Email"
1023
+ checked={data.notifications.email.enabled}
1024
+ path="notifications.email.enabled"
1025
+ />
1026
+ {data.notifications.email.enabled && (
1027
+ <box flexDirection="row" gap={1} paddingLeft={3}>
1028
+ <text fg={Colors.muted} width={10}>
1029
+ Address
1030
+ </text>
1031
+ <text fg={Colors.foreground}>
1032
+ {data.notifications.email.address || "(enter address)"}
1033
+ </text>
1034
+ <ValidationIcon fieldPath="notifications.email.address" />
1035
+ </box>
1036
+ )}
1037
+ </box>
1038
+
1039
+ <box flexDirection="column" gap={0}>
1040
+ <Checkbox
1041
+ label="Telegram"
1042
+ checked={data.notifications.telegram.enabled}
1043
+ path="notifications.telegram.enabled"
1044
+ />
1045
+ {data.notifications.telegram.enabled && (
1046
+ <box flexDirection="column" gap={0} paddingLeft={3}>
1047
+ <SecretField
1048
+ label="Bot Token"
1049
+ value={data.notifications.telegram.botToken}
1050
+ path="notifications.telegram.botToken"
1051
+ />
1052
+ <ValidationIcon fieldPath="notifications.telegram.botToken" />
1053
+ <box flexDirection="row" gap={1}>
1054
+ <text fg={Colors.muted} width={10}>
1055
+ Chat ID
1056
+ </text>
1057
+ <text fg={Colors.foreground}>
1058
+ {data.notifications.telegram.chatId || "(enter ID)"}
1059
+ </text>
1060
+ </box>
1061
+ </box>
1062
+ )}
1063
+ </box>
1064
+
1065
+ <box flexDirection="column" gap={0}>
1066
+ <Checkbox
1067
+ label="Discord"
1068
+ checked={data.notifications.discord.enabled}
1069
+ path="notifications.discord.enabled"
1070
+ />
1071
+ {data.notifications.discord.enabled && (
1072
+ <box flexDirection="row" gap={1} paddingLeft={3}>
1073
+ <text fg={Colors.muted} width={12}>
1074
+ Webhook URL
1075
+ </text>
1076
+ <text
1077
+ fg={
1078
+ data.notifications.discord.webhookUrl
1079
+ ? Colors.foreground
1080
+ : Colors.dim
1081
+ }
1082
+ >
1083
+ {data.notifications.discord.webhookUrl
1084
+ ? maskSecret(data.notifications.discord.webhookUrl)
1085
+ : "(enter URL)"}
1086
+ </text>
1087
+ <ValidationIcon fieldPath="notifications.discord.webhookUrl" />
1088
+ </box>
1089
+ )}
1090
+ </box>
1091
+ </box>
1092
+ </box>
1093
+ );
1094
+
1095
+ /** Step 6: Deploy — Summary view */
1096
+ const StepDeploy = () => {
1097
+ const activeCount = Object.values(data.exchanges).filter(Boolean).length;
1098
+ const apiKeysConfigured = Object.values(data.apiKeys).filter(
1099
+ (e) => e.key || e.secret
1100
+ ).length;
1101
+ const notifCount = [
1102
+ data.notifications.email.enabled,
1103
+ data.notifications.telegram.enabled,
1104
+ data.notifications.discord.enabled,
1105
+ ].filter(Boolean).length;
1106
+
1107
+ return (
1108
+ <box flexDirection="column" gap={0}>
1109
+ {deploying ? (
1110
+ <>
1111
+ <text bold fg={Colors.accent}>
1112
+ Deploying…
1113
+ </text>
1114
+ <text dim fg={Colors.muted}>
1115
+ Running hoox deploy all — this may take a few minutes.
1116
+ </text>
1117
+ <box
1118
+ flexDirection="column"
1119
+ gap={0}
1120
+ paddingTop={1}
1121
+ height={10}
1122
+ overflow="hidden"
1123
+ >
1124
+ <scrollbox border={false} flexGrow={1}>
1125
+ <text fg={Colors.foreground}>{deployLog || "Starting…\n"}</text>
1126
+ </scrollbox>
1127
+ </box>
1128
+ <box paddingTop={1}>
1129
+ <text dim fg={Colors.muted}>
1130
+ Deploying workers, please wait…
1131
+ </text>
1132
+ </box>
1133
+ </>
1134
+ ) : deployLog ? (
1135
+ <>
1136
+ <text bold fg={Colors.error}>
1137
+ Deploy Failed
1138
+ </text>
1139
+ <box
1140
+ flexDirection="column"
1141
+ gap={0}
1142
+ paddingTop={1}
1143
+ height={8}
1144
+ overflow="hidden"
1145
+ >
1146
+ <scrollbox border={false} flexGrow={1}>
1147
+ <text fg={Colors.error}>{deployLog}</text>
1148
+ </scrollbox>
1149
+ </box>
1150
+ <box paddingTop={2} justifyContent="center">
1151
+ <text
1152
+ fg={Colors.accent}
1153
+ bg={Colors.card}
1154
+ bold
1155
+ onMouseUp={() => {
1156
+ setDeployLog("");
1157
+ handleDeploy();
1158
+ }}
1159
+ >
1160
+ {" [ Retry Deploy ] "}
1161
+ </text>
1162
+ </box>
1163
+ </>
1164
+ ) : (
1165
+ <>
1166
+ <text bold fg={Colors.accent}>
1167
+ Ready to Deploy
1168
+ </text>
1169
+ <text dim fg={Colors.muted}>
1170
+ Review your configuration before deploying.
1171
+ </text>
1172
+ <box flexDirection="column" gap={0} paddingTop={1}>
1173
+ <text fg={Colors.foreground}>
1174
+ Exchanges: {activeCount} active (
1175
+ {data.exchanges.binance ? "Binance " : ""}
1176
+ {data.exchanges.bybit ? "Bybit " : ""}
1177
+ {data.exchanges.mexc ? "MEXC" : ""}
1178
+ {activeCount === 0 ? "none" : ""})
1179
+ </text>
1180
+ <text fg={Colors.foreground}>
1181
+ API Keys: {apiKeysConfigured}/3 configured
1182
+ </text>
1183
+ <text fg={Colors.foreground}>
1184
+ AI Provider:{" "}
1185
+ {data.ai.providerUrl ? data.ai.model : "not configured"}
1186
+ </text>
1187
+ <text fg={Colors.foreground}>Strategy: {data.strategy.type}</text>
1188
+ <text fg={Colors.foreground}>
1189
+ Notifications: {notifCount} channels
1190
+ {data.notifications.email.enabled ? " Email" : ""}
1191
+ {data.notifications.telegram.enabled ? " Telegram" : ""}
1192
+ {data.notifications.discord.enabled ? " Discord" : ""}
1193
+ {notifCount === 0 ? " none" : ""}
1194
+ </text>
1195
+ </box>
1196
+ <box paddingTop={2} justifyContent="center">
1197
+ <text
1198
+ fg={deploying ? Colors.dim : Colors.accent}
1199
+ bg={deploying ? Colors.border : Colors.card}
1200
+ bold
1201
+ onMouseUp={deploying ? undefined : handleDeploy}
1202
+ >
1203
+ {" [ Deploy Now ] "}
1204
+ </text>
1205
+ </box>
1206
+ </>
1207
+ )}
1208
+ </box>
1209
+ );
1210
+ };
1211
+
1212
+ // ─── Progress Indicator ─────────────────────────────────────────────────
1213
+
1214
+ const progressLine = SETUP_STEPS.map((label, i) => {
1215
+ const isFilled = i <= step;
1216
+ const isCurrent = i === step;
1217
+ return (
1218
+ <text
1219
+ fg={isCurrent ? Colors.accent : isFilled ? Colors.success : Colors.dim}
1220
+ bold={isCurrent}
1221
+ >
1222
+ {isFilled ? "█" : "░"} {label}
1223
+ {" "}
1224
+ </text>
1225
+ );
1226
+ });
1227
+
1228
+ // ─── Current Step Content ────────────────────────────────────────────────
1229
+
1230
+ const stepContent = (() => {
1231
+ switch (step) {
1232
+ case 0:
1233
+ return <StepPrerequisites />;
1234
+ case 1:
1235
+ return <StepApiKeys />;
1236
+ case 2:
1237
+ return <StepExchanges />;
1238
+ case 3:
1239
+ return <StepAiProviders />;
1240
+ case 4:
1241
+ return <StepStrategies />;
1242
+ case 5:
1243
+ return <StepNotifications />;
1244
+ case 6:
1245
+ return <StepDeploy />;
1246
+ default:
1247
+ return <text>Unknown step</text>;
1248
+ }
1249
+ })();
1250
+
1251
+ const isFirstStep = step === 0;
1252
+ const isLastStep = step === TOTAL_SETUP_STEPS - 1;
1253
+ const canSkip = step === 2 || step === 4;
1254
+
1255
+ return (
1256
+ <ErrorBoundary viewName="Setup Wizard">
1257
+ <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
1258
+ <ViewHeader
1259
+ title={`SETUP — STEP ${step + 1}/${TOTAL_SETUP_STEPS}`}
1260
+ showDivider={false}
1261
+ meta={
1262
+ <text fg={Colors.muted} dim>
1263
+ {SETUP_STEPS[step]}
1264
+ </text>
1265
+ }
1266
+ />
1267
+
1268
+ {/* Progress Indicator */}
1269
+ <box flexDirection="row" gap={0} paddingBottom={1}>
1270
+ {progressLine}
1271
+ </box>
1272
+
1273
+ {/* Separator */}
1274
+ <text fg={Colors.border} dim>
1275
+ {"─".repeat(TOTAL_SETUP_STEPS * 16)}
1276
+ </text>
1277
+
1278
+ {/* Step Content (scrollbox for overflow) */}
1279
+ <scrollbox flexGrow={1} border={false}>
1280
+ {stepContent}
1281
+ </scrollbox>
1282
+
1283
+ {stepHint ? (
1284
+ <text fg={Colors.warning} dim>
1285
+ {stepHint}
1286
+ </text>
1287
+ ) : null}
1288
+
1289
+ {/* Navigation Bar */}
1290
+ <box flexDirection="row" justifyContent="space-between" paddingTop={1}>
1291
+ <text
1292
+ fg={isFirstStep ? Colors.dim : Colors.muted}
1293
+ onMouseUp={isFirstStep ? undefined : handleBack}
1294
+ >
1295
+ {isFirstStep ? " " : "[← BACK]"}
1296
+ </text>
1297
+
1298
+ <text
1299
+ fg={canSkip && !isLastStep ? Colors.muted : Colors.dim}
1300
+ onMouseUp={canSkip && !isLastStep ? handleSkip : undefined}
1301
+ >
1302
+ {canSkip && !isLastStep ? "[SKIP]" : ""}
1303
+ </text>
1304
+
1305
+ <text
1306
+ fg={isLastStep ? Colors.dim : Colors.accent}
1307
+ bold={!isLastStep}
1308
+ onMouseUp={isLastStep ? undefined : handleNext}
1309
+ >
1310
+ {isLastStep ? "" : "[NEXT →]"}
1311
+ </text>
1312
+ </box>
1313
+
1314
+ {/* Keybindings hint */}
1315
+ <text dim fg={Colors.dim}>
1316
+ ← → NAVIGATE · ESC BACK · R RE-RUN CHECKS · OPTIONAL STEPS SKIPPABLE
1317
+ </text>
1318
+ </box>
1319
+ </ErrorBoundary>
1320
+ );
1321
+ }