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