@jango-blockchained/hoox-cli 0.3.4

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 (94) hide show
  1. package/README.md +403 -0
  2. package/bin/hoox.js +12 -0
  3. package/package.json +60 -0
  4. package/src/commands/check/check-command.test.ts +468 -0
  5. package/src/commands/check/check-command.ts +1144 -0
  6. package/src/commands/check/index.ts +10 -0
  7. package/src/commands/check/prerequisites-command.test.ts +19 -0
  8. package/src/commands/check/prerequisites-command.ts +92 -0
  9. package/src/commands/check/types.ts +103 -0
  10. package/src/commands/clone/clone-command.test.ts +442 -0
  11. package/src/commands/clone/clone-command.ts +440 -0
  12. package/src/commands/clone/index.ts +1 -0
  13. package/src/commands/config/config-command.test.ts +583 -0
  14. package/src/commands/config/config-command.ts +901 -0
  15. package/src/commands/config/env-command.test.ts +43 -0
  16. package/src/commands/config/env-command.ts +314 -0
  17. package/src/commands/config/index.ts +3 -0
  18. package/src/commands/config/kv-command.test.ts +14 -0
  19. package/src/commands/config/kv-command.ts +329 -0
  20. package/src/commands/dashboard/dashboard-command.test.ts +47 -0
  21. package/src/commands/dashboard/dashboard-command.ts +127 -0
  22. package/src/commands/dashboard/index.ts +1 -0
  23. package/src/commands/db/db-command.test.ts +21 -0
  24. package/src/commands/db/db-command.ts +314 -0
  25. package/src/commands/db/index.ts +1 -0
  26. package/src/commands/deploy/deploy-command.test.ts +304 -0
  27. package/src/commands/deploy/deploy-command.ts +1053 -0
  28. package/src/commands/deploy/index.ts +2 -0
  29. package/src/commands/deploy/telegram-service.ts +61 -0
  30. package/src/commands/deploy/types.ts +34 -0
  31. package/src/commands/dev/dev-command.test.ts +383 -0
  32. package/src/commands/dev/dev-command.ts +407 -0
  33. package/src/commands/dev/index.ts +1 -0
  34. package/src/commands/infra/index.ts +5 -0
  35. package/src/commands/infra/infra-command.test.ts +719 -0
  36. package/src/commands/infra/infra-command.ts +940 -0
  37. package/src/commands/infra/types.ts +23 -0
  38. package/src/commands/init/index.ts +1 -0
  39. package/src/commands/init/init-command.test.ts +827 -0
  40. package/src/commands/init/init-command.ts +627 -0
  41. package/src/commands/init/types.ts +185 -0
  42. package/src/commands/monitor/index.ts +2 -0
  43. package/src/commands/monitor/monitor-command.test.ts +235 -0
  44. package/src/commands/monitor/monitor-command.ts +245 -0
  45. package/src/commands/monitor/monitor-service.ts +50 -0
  46. package/src/commands/monitor/types.ts +13 -0
  47. package/src/commands/repair/index.ts +2 -0
  48. package/src/commands/repair/repair-command.test.ts +204 -0
  49. package/src/commands/repair/repair-command.ts +199 -0
  50. package/src/commands/repair/repair-service.ts +102 -0
  51. package/src/commands/repair/types.ts +13 -0
  52. package/src/commands/test/index.ts +2 -0
  53. package/src/commands/test/test-command.test.ts +319 -0
  54. package/src/commands/test/test-command.ts +412 -0
  55. package/src/commands/waf/index.ts +2 -0
  56. package/src/commands/waf/types.ts +48 -0
  57. package/src/commands/waf/waf-command.test.ts +506 -0
  58. package/src/commands/waf/waf-command.ts +548 -0
  59. package/src/index.ts +198 -0
  60. package/src/services/cloudflare/cloudflare-service.test.ts +654 -0
  61. package/src/services/cloudflare/cloudflare-service.ts +435 -0
  62. package/src/services/cloudflare/index.ts +2 -0
  63. package/src/services/cloudflare/types.ts +29 -0
  64. package/src/services/config/config-service.test.ts +395 -0
  65. package/src/services/config/config-service.ts +207 -0
  66. package/src/services/config/index.ts +9 -0
  67. package/src/services/config/types.ts +66 -0
  68. package/src/services/db/db-service.test.ts +51 -0
  69. package/src/services/db/db-service.ts +140 -0
  70. package/src/services/db/index.ts +1 -0
  71. package/src/services/docker/docker-service.ts +155 -0
  72. package/src/services/docker/index.ts +1 -0
  73. package/src/services/env/env-service.test.ts +210 -0
  74. package/src/services/env/env-service.ts +156 -0
  75. package/src/services/env/index.ts +1 -0
  76. package/src/services/kv/index.ts +1 -0
  77. package/src/services/kv/kv-sync-service.test.ts +38 -0
  78. package/src/services/kv/kv-sync-service.ts +151 -0
  79. package/src/services/prerequisites/index.ts +1 -0
  80. package/src/services/prerequisites/prerequisites-service.test.ts +89 -0
  81. package/src/services/prerequisites/prerequisites-service.ts +269 -0
  82. package/src/services/prerequisites/types.ts +48 -0
  83. package/src/services/secrets/index.ts +12 -0
  84. package/src/services/secrets/secrets-service.test.ts +486 -0
  85. package/src/services/secrets/secrets-service.ts +293 -0
  86. package/src/services/secrets/types.ts +57 -0
  87. package/src/ui/banner.ts +52 -0
  88. package/src/ui/index.ts +8 -0
  89. package/src/ui/menu.ts +473 -0
  90. package/src/utils/errors.test.ts +69 -0
  91. package/src/utils/errors.ts +23 -0
  92. package/src/utils/formatters.test.ts +180 -0
  93. package/src/utils/formatters.ts +252 -0
  94. package/src/utils/theme.ts +94 -0
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * `hoox deploy` command group — deploy workers and dashboard to Cloudflare.
3
+ *
4
+ * Subcommands:
5
+ * all — Deploy all enabled workers, then the dashboard
6
+ * workers — Deploy all enabled workers with spinner + summary table
7
+ * worker — Deploy a single worker with optional --env flag
8
+ * dashboard — Build and deploy the Next.js dashboard via OpenNext
9
+ */
10
+ import { Command } from "commander";
11
+ import { spinner, log, select } from "@clack/prompts";
12
+ import { ConfigService } from "../../services/config/index.js";
13
+ import { CloudflareService } from "../../services/cloudflare/index.js";
14
+ import { theme, icons } from "../../utils/theme.js";
15
+ import {
16
+ formatSuccess,
17
+ formatError,
18
+ type FormatOptions,
19
+ } from "../../utils/formatters.js";
20
+ import { CLIError, ExitCode } from "../../utils/errors.js";
21
+ import type { DeployResult } from "./types.js";
22
+ import { statSync, existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
+ import { TelegramService } from "./telegram-service.js";
25
+ import { EnvService } from "../../services/env/index.js";
26
+ import * as jsonc from "jsonc-parser";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Check if dashboard build exists and get its modification date.
34
+ * Returns null if no build exists.
35
+ */
36
+ function getDashboardBuildInfo(dashboardPath: string): {
37
+ exists: boolean;
38
+ lastModified?: Date;
39
+ age?: string;
40
+ } {
41
+ const workerPath = resolve(dashboardPath, ".open-next", "worker.js");
42
+
43
+ if (!existsSync(workerPath)) {
44
+ return { exists: false };
45
+ }
46
+
47
+ const stats = statSync(workerPath);
48
+ const lastModified = stats.mtime;
49
+ const now = new Date();
50
+ const ageMs = now.getTime() - lastModified.getTime();
51
+ const ageMinutes = Math.floor(ageMs / 60000);
52
+ const ageHours = Math.floor(ageMinutes / 60);
53
+ const ageDays = Math.floor(ageHours / 24);
54
+
55
+ let age: string;
56
+ if (ageDays > 0) {
57
+ age = `${ageDays} day${ageDays > 1 ? "s" : ""} ago`;
58
+ } else if (ageHours > 0) {
59
+ age = `${ageHours} hour${ageHours > 1 ? "s" : ""} ago`;
60
+ } else if (ageMinutes > 0) {
61
+ age = `${ageMinutes} minute${ageMinutes > 1 ? "s" : ""} ago`;
62
+ } else {
63
+ age = "just now";
64
+ }
65
+
66
+ return { exists: true, lastModified, age };
67
+ }
68
+
69
+ /**
70
+ * Ask user whether to rebuild or use existing build.
71
+ * Returns:
72
+ * - "rebuild" if user wants to rebuild
73
+ * - "deploy" if user wants to use existing build
74
+ * - "cancel" if user wants to abort
75
+ */
76
+ async function promptRebuildDecision(buildInfo: {
77
+ exists: boolean;
78
+ lastModified?: Date;
79
+ age?: string;
80
+ }): Promise<"rebuild" | "deploy" | "cancel"> {
81
+ if (!buildInfo.exists) {
82
+ return "rebuild"; // No build exists, auto-build
83
+ }
84
+
85
+ const choice = await select({
86
+ message: `Dashboard was last built ${buildInfo.age}. What would you like to do?`,
87
+ options: [
88
+ {
89
+ value: "rebuild",
90
+ label: "Build new (recommended)",
91
+ hint: "rebuild before deploy",
92
+ },
93
+ {
94
+ value: "deploy",
95
+ label: "Deploy existing",
96
+ hint: `from ${buildInfo.age}`,
97
+ },
98
+ { value: "cancel", label: "Cancel", hint: "go back" },
99
+ ],
100
+ });
101
+
102
+ if (choice === undefined || choice === "cancel") {
103
+ return "cancel";
104
+ }
105
+
106
+ return choice as "rebuild" | "deploy";
107
+ }
108
+
109
+ /**
110
+ * Build the format options for output, reading global --json / --quiet flags.
111
+ */
112
+ function getFormatOptions(cmd: Command) {
113
+ const opts = cmd.optsWithGlobals();
114
+ return { json: Boolean(opts.json), quiet: Boolean(opts.quiet) };
115
+ }
116
+
117
+ /**
118
+ * Deploy a single worker via CloudflareService.deploy().
119
+ * Returns a DeployResult summarizing the outcome.
120
+ */
121
+ async function deploySingle(
122
+ configService: ConfigService,
123
+ cf: CloudflareService,
124
+ workerName: string,
125
+ env?: string
126
+ ): Promise<DeployResult> {
127
+ const workerConfig = configService.getWorker(workerName);
128
+
129
+ if (!workerConfig) {
130
+ return {
131
+ worker: workerName,
132
+ success: false,
133
+ error: `Worker "${workerName}" not found in wrangler.jsonc`,
134
+ };
135
+ }
136
+
137
+ const result = await cf.deploy(workerConfig.path, env);
138
+
139
+ if (result.ok) {
140
+ const rawLine = result.data.rawOutput
141
+ ?.split("\n")
142
+ .find((l) => l.trim())
143
+ ?.trim();
144
+ return {
145
+ worker: workerName,
146
+ url: result.data.url,
147
+ success: true,
148
+ size: result.data.size,
149
+ startupTime: result.data.startupTime,
150
+ versionId: result.data.versionId,
151
+ rawOutput: rawLine,
152
+ };
153
+ }
154
+
155
+ return {
156
+ worker: workerName,
157
+ success: false,
158
+ error: result.error,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Published deployment order. Follows the dependency chain documented in
164
+ * docs/setup_and_operations.md so that service bindings are available
165
+ * before dependent workers are deployed.
166
+ */
167
+ const DEPLOY_ORDER: string[] = [
168
+ "analytics-worker",
169
+ "d1-worker",
170
+ "telegram-worker",
171
+ "web3-wallet-worker",
172
+ "email-worker",
173
+ "trade-worker",
174
+ "agent-worker",
175
+ "hoox",
176
+ "dashboard",
177
+ ];
178
+
179
+ /**
180
+ * Deploy all enabled workers + dashboard with interactive progress UI.
181
+ * Uses a single list that updates in place with spinner and details.
182
+ */
183
+ async function deployAll(
184
+ configService: ConfigService,
185
+ cf: CloudflareService,
186
+ env?: string,
187
+ forceRebuildDashboard: boolean = false,
188
+ autoMode: boolean = false
189
+ ): Promise<DeployResult[]> {
190
+ // Get enabled workers and sort by deployment order
191
+ const enabled = configService.listEnabledWorkers();
192
+ const workers = DEPLOY_ORDER.filter(
193
+ (w) => w !== "dashboard" && enabled.includes(w)
194
+ );
195
+ // Append any unknown workers (not in DEPLOY_ORDER) at the end
196
+ const unknown = enabled.filter(
197
+ (w) => w !== "dashboard" && !DEPLOY_ORDER.includes(w)
198
+ );
199
+ const allItems = [...workers, ...unknown, "dashboard"];
200
+ const results: DeployResult[] = [];
201
+
202
+ if (allItems.length === 0) {
203
+ return results;
204
+ }
205
+
206
+ // Use clack spinner for each worker
207
+ const s = spinner();
208
+ s.start(`Deploying ${allItems.length} item(s)...`);
209
+
210
+ for (let i = 0; i < allItems.length; i++) {
211
+ const name = allItems[i];
212
+ const isDashboard = name === "dashboard";
213
+
214
+ s.message(`[${i + 1}/${allItems.length}] ${name}...`);
215
+
216
+ let result: DeployResult;
217
+
218
+ if (isDashboard) {
219
+ // Use the silent mode in deployAll since we manage our own UI
220
+ result = await deployDashboard(cf, forceRebuildDashboard, true, autoMode);
221
+ } else {
222
+ result = await deploySingle(configService, cf, name, env);
223
+ }
224
+
225
+ results.push(result);
226
+
227
+ if (result.success) {
228
+ s.stop(`${theme.success(icons.success)} ${name} deployed`);
229
+ } else {
230
+ s.stop(`${theme.error(icons.error)} ${name} failed`);
231
+ }
232
+
233
+ // Output details below the spinner line
234
+ if (result.success) {
235
+ if (result.url) {
236
+ log.step(` ${theme.dim("URL:")} ${result.url}`);
237
+ }
238
+ if (result.size) {
239
+ log.step(` ${theme.dim("Size:")} ${result.size}`);
240
+ }
241
+ if (result.startupTime) {
242
+ log.step(` ${theme.dim("Startup:")} ${result.startupTime}`);
243
+ }
244
+ if (result.versionId) {
245
+ log.step(` ${theme.dim("Version:")} ${result.versionId.slice(0, 8)}...`);
246
+ }
247
+ if (
248
+ !result.url &&
249
+ !result.size &&
250
+ !result.startupTime &&
251
+ !result.versionId &&
252
+ result.rawOutput
253
+ ) {
254
+ log.step(` ${theme.dim("Output:")} ${result.rawOutput.slice(0, 80)}`);
255
+ }
256
+ } else if (result.error) {
257
+ log.warn(` ${theme.error("Error:")} ${result.error}`);
258
+ }
259
+
260
+ // Start spinner for next worker
261
+ if (i < allItems.length - 1) {
262
+ s.start(`Deploying ${allItems.length} item(s)...`);
263
+ }
264
+ }
265
+
266
+ // Summary line
267
+ const succeeded = results.filter((r) => r.success).length;
268
+ const failed = results.filter((r) => !r.success).length;
269
+ if (failed > 0) {
270
+ log.warn(`Summary: ${succeeded}/${allItems.length} deployed (${failed} failed)`);
271
+ }
272
+
273
+ return results;
274
+ }
275
+
276
+ /**
277
+ * Build and deploy the dashboard (workers/dashboard) via OpenNext + wrangler.
278
+ * @param silentMode If true, skips prompt and header for use in deployAll
279
+ */
280
+ async function deployDashboard(
281
+ cf: CloudflareService,
282
+ forceRebuild: boolean = false,
283
+ silentMode: boolean = false,
284
+ autoMode: boolean = false
285
+ ): Promise<DeployResult> {
286
+ const dashboardPath = "workers/dashboard";
287
+
288
+ // Check existing build status
289
+ const buildInfo = getDashboardBuildInfo(dashboardPath);
290
+
291
+ // Determine action based on build status and user choice
292
+ let action: "rebuild" | "deploy" | "cancel";
293
+ if (forceRebuild) {
294
+ action = "rebuild";
295
+ } else if (autoMode) {
296
+ action = buildInfo.exists ? "deploy" : "rebuild";
297
+ } else if (silentMode) {
298
+ action = "rebuild";
299
+ } else {
300
+ action = await promptRebuildDecision(buildInfo);
301
+ }
302
+
303
+ // Handle cancel
304
+ if (action === "cancel") {
305
+ return {
306
+ worker: "dashboard",
307
+ success: false,
308
+ error: "Cancelled by user",
309
+ };
310
+ }
311
+
312
+ // Only show header if not in silent mode (deployAll handles its own UI)
313
+ if (!silentMode) {
314
+ process.stdout.write(`\n${theme.heading("Deploying Dashboard")}\n`);
315
+ process.stdout.write(`${theme.dim("─".repeat(50))}\n`);
316
+ process.stdout.write(`${theme.dim("○")} dashboard\n`);
317
+ process.stdout.write(`${theme.dim("─".repeat(50))}\n\n`);
318
+ }
319
+
320
+ // Use clack spinner
321
+ const actionText =
322
+ action === "rebuild" ? "Building & deploying" : "Deploying";
323
+ const s = spinner();
324
+
325
+ try {
326
+ s.start(`${actionText} dashboard...`);
327
+
328
+ if (action === "rebuild") {
329
+ // Build + Deploy: bun run deploy (runs opennext:build && opennext:deploy)
330
+ const buildProc = Bun.spawn(["bun", "run", "opennext:deploy"], {
331
+ cwd: dashboardPath,
332
+ stdout: "pipe",
333
+ stderr: "pipe",
334
+ });
335
+
336
+ const buildExit = await buildProc.exited;
337
+ const output = await new Response(buildProc.stdout).text();
338
+
339
+ if (buildExit !== 0) {
340
+ const error = await new Response(buildProc.stderr).text();
341
+ s.stop(`${theme.error(icons.error)} dashboard failed`);
342
+ if (!silentMode) {
343
+ process.stdout.write(
344
+ ` ${theme.dim("Error:")} ${error.split("\n")[0]}\n`
345
+ );
346
+ }
347
+ return {
348
+ worker: "dashboard",
349
+ success: false,
350
+ error: `Dashboard deploy exited with code ${buildExit}`,
351
+ };
352
+ }
353
+
354
+ // Parse output for metrics
355
+ const sizeMatch = output.match(
356
+ /Total Upload:\s*([\d.]+)\s*([KMGT]?i?B)/i
357
+ );
358
+ const startupMatch = output.match(/Worker Startup Time:\s*(\d+)\s*ms/i);
359
+ const urlMatch = output.match(
360
+ /https?:\/\/dashboard\.[a-zA-Z0-9-]+\.workers\.dev/
361
+ );
362
+
363
+ s.stop(`${theme.success(icons.success)} dashboard deployed`);
364
+
365
+ if (!silentMode) {
366
+ if (urlMatch) {
367
+ process.stdout.write(` ${theme.dim("URL:")} ${urlMatch[0]}\n`);
368
+ }
369
+ if (sizeMatch) {
370
+ process.stdout.write(
371
+ ` ${theme.dim("Size:")} ${sizeMatch[1]} ${sizeMatch[2]}\n`
372
+ );
373
+ }
374
+ if (startupMatch) {
375
+ process.stdout.write(
376
+ ` ${theme.dim("Startup:")} ${startupMatch[1]} ms\n`
377
+ );
378
+ }
379
+ }
380
+
381
+ return {
382
+ worker: "dashboard",
383
+ url: urlMatch?.[0] || "https://dashboard.cryptolinx.workers.dev",
384
+ success: true,
385
+ size: sizeMatch ? `${sizeMatch[1]} ${sizeMatch[2]}` : undefined,
386
+ startupTime: startupMatch ? `${startupMatch[1]} ms` : undefined,
387
+ };
388
+ } else {
389
+ // Deploy only (no build)
390
+ const deployProc = Bun.spawn(["bun", "run", "opennext:deploy"], {
391
+ cwd: dashboardPath,
392
+ stdout: "pipe",
393
+ stderr: "pipe",
394
+ });
395
+
396
+ const deployExit = await deployProc.exited;
397
+ const output = await new Response(deployProc.stdout).text();
398
+
399
+ if (deployExit !== 0) {
400
+ const error = await new Response(deployProc.stderr).text();
401
+ s.stop(`${theme.error(icons.error)} dashboard failed`);
402
+ if (!silentMode) {
403
+ process.stdout.write(
404
+ ` ${theme.dim("Error:")} ${error.split("\n")[0]}\n`
405
+ );
406
+ }
407
+ return {
408
+ worker: "dashboard",
409
+ success: false,
410
+ error: `Dashboard deploy exited with code ${deployExit}`,
411
+ };
412
+ }
413
+
414
+ const urlMatch = output.match(
415
+ /https?:\/\/dashboard\.[a-zA-Z0-9-]+\.workers\.dev/
416
+ );
417
+
418
+ s.stop(`${theme.success(icons.success)} dashboard deployed`);
419
+
420
+ if (!silentMode) {
421
+ if (urlMatch) {
422
+ process.stdout.write(` ${theme.dim("URL:")} ${urlMatch[0]}\n`);
423
+ }
424
+ }
425
+
426
+ return {
427
+ worker: "dashboard",
428
+ url: urlMatch?.[0] || "https://dashboard.cryptolinx.workers.dev",
429
+ success: true,
430
+ };
431
+ }
432
+ } catch (err) {
433
+ const message = err instanceof Error ? err.message : String(err);
434
+ s.stop(`${theme.error(icons.error)} dashboard failed`);
435
+ if (!silentMode) {
436
+ process.stdout.write(` ${theme.dim("Error:")} ${message}\n`);
437
+ }
438
+ return {
439
+ worker: "dashboard",
440
+ success: false,
441
+ error: message,
442
+ };
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Print a summary table of deploy results.
448
+ * Excluded in quiet mode; uses JSON or box-drawn table depending on --json.
449
+ */
450
+ function printSummary(
451
+ results: DeployResult[],
452
+ opts: { json?: boolean; quiet?: boolean }
453
+ ): void {
454
+ if (opts.quiet) return;
455
+
456
+ // Separate workers from dashboard for different formatting
457
+ const workerResults = results.filter((r) => r.worker !== "dashboard");
458
+ const dashboardResult = results.find((r) => r.worker === "dashboard");
459
+
460
+ // Print worker deployments with verbose info
461
+ if (workerResults.length > 0) {
462
+ process.stdout.write(`\n${theme.heading("Workers Deployed")}\n`);
463
+ for (const r of workerResults) {
464
+ const icon = r.success
465
+ ? theme.success(icons.success)
466
+ : theme.error(icons.error);
467
+ const status = r.success ? "deployed" : "failed";
468
+ process.stdout.write(`${icon} ${r.worker}: ${status}\n`);
469
+
470
+ if (r.success && r.url) {
471
+ process.stdout.write(` ${theme.dim("URL:")} ${r.url}\n`);
472
+ }
473
+ if (r.success && r.size) {
474
+ process.stdout.write(` ${theme.dim("Size:")} ${r.size}\n`);
475
+ }
476
+ if (r.success && r.startupTime) {
477
+ process.stdout.write(` ${theme.dim("Startup:")} ${r.startupTime}\n`);
478
+ }
479
+ if (r.success && r.versionId) {
480
+ process.stdout.write(
481
+ ` ${theme.dim("Version:")} ${r.versionId.slice(0, 8)}...\n`
482
+ );
483
+ }
484
+ if (!r.success && r.error) {
485
+ process.stdout.write(` ${theme.error("Error:")} ${r.error}\n`);
486
+ }
487
+ }
488
+ }
489
+
490
+ // Print dashboard deployment
491
+ if (dashboardResult) {
492
+ process.stdout.write(`\n${theme.heading("Dashboard Deployed")}\n`);
493
+ const icon = dashboardResult.success
494
+ ? theme.success(icons.success)
495
+ : theme.error(icons.error);
496
+ const status = dashboardResult.success ? "deployed" : "failed";
497
+ process.stdout.write(`${icon} dashboard: ${status}\n`);
498
+
499
+ if (dashboardResult.success && dashboardResult.url) {
500
+ process.stdout.write(` ${theme.dim("URL:")} ${dashboardResult.url}\n`);
501
+ }
502
+ if (dashboardResult.success && dashboardResult.size) {
503
+ process.stdout.write(
504
+ ` ${theme.dim("Size:")} ${dashboardResult.size}\n`
505
+ );
506
+ }
507
+ if (dashboardResult.success && dashboardResult.startupTime) {
508
+ process.stdout.write(
509
+ ` ${theme.dim("Startup:")} ${dashboardResult.startupTime}\n`
510
+ );
511
+ }
512
+ if (!dashboardResult.success && dashboardResult.error) {
513
+ process.stdout.write(
514
+ ` ${theme.error("Error:")} ${dashboardResult.error}\n`
515
+ );
516
+ }
517
+ }
518
+
519
+ // Summary line
520
+ const succeeded = results.filter((r) => r.success).length;
521
+ const failed = results.filter((r) => !r.success).length;
522
+ process.stdout.write(
523
+ `\n${theme.heading("Summary:")} ${succeeded} succeeded, ${failed} failed\n`
524
+ );
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Telegram webhook handler
529
+ // ---------------------------------------------------------------------------
530
+
531
+ async function doTelegramWebhook(
532
+ fmt: FormatOptions,
533
+ token?: string,
534
+ secretToken?: string,
535
+ subdomain?: string,
536
+ ): Promise<void> {
537
+ try {
538
+ // Resolve subdomain from flag or config
539
+ let prefix = subdomain;
540
+ if (!prefix) {
541
+ const config = new ConfigService();
542
+ await config.load();
543
+ const global = config.getGlobal();
544
+ prefix = global.subdomain_prefix ?? "hoox";
545
+ }
546
+
547
+ // Resolve bot token from flag or .env.local
548
+ let botToken = token;
549
+ if (!botToken) {
550
+ const envVars = await EnvService.loadDotEnvAsync(".env.local");
551
+ botToken = envVars["TELEGRAM_BOT_TOKEN"];
552
+ }
553
+ if (!botToken) {
554
+ formatError(new CLIError("Telegram bot token not found. Provide --token or set TELEGRAM_BOT_TOKEN in .env.local", ExitCode.ERROR), fmt);
555
+ process.exitCode = ExitCode.ERROR;
556
+ return;
557
+ }
558
+
559
+ // Resolve secret token from flag or .env.local
560
+ let webhookSecret = secretToken;
561
+ if (!webhookSecret) {
562
+ const envVars = await EnvService.loadDotEnvAsync(".env.local");
563
+ webhookSecret = envVars["TELEGRAM_SECRET_TOKEN"];
564
+ }
565
+ if (!webhookSecret) {
566
+ formatError(new CLIError("Telegram secret token not found. Provide --secret-token or set TELEGRAM_SECRET_TOKEN in .env.local", ExitCode.ERROR), fmt);
567
+ process.exitCode = ExitCode.ERROR;
568
+ return;
569
+ }
570
+
571
+ // Check current webhook status
572
+ const telegram = new TelegramService();
573
+ const webhookUrl = `https://telegram-worker.${prefix}.workers.dev/webhook/${webhookSecret}`;
574
+
575
+ const info = await telegram.getWebhookInfo(botToken);
576
+ if (info.ok && info.url) {
577
+ process.stdout.write(`${theme.dim("Current webhook:")} ${info.url}\n`);
578
+ if (info.pending_update_count !== undefined) {
579
+ process.stdout.write(`${theme.dim("Pending updates:")} ${info.pending_update_count}\n`);
580
+ }
581
+ }
582
+
583
+ // Set webhook
584
+ process.stdout.write(`${theme.info("Setting webhook to:")} ${webhookUrl}\n`);
585
+ const result = await telegram.setWebhook(botToken, webhookUrl, webhookSecret);
586
+
587
+ if (result.ok) {
588
+ formatSuccess("Telegram webhook set successfully", fmt);
589
+ if (result.description) process.stdout.write(` ${theme.dim(result.description)}\n`);
590
+ } else {
591
+ formatError(new CLIError(`Telegram webhook failed: ${result.error || result.description || "Unknown error"}`, ExitCode.ERROR), fmt);
592
+ process.exitCode = ExitCode.ERROR;
593
+ }
594
+ } catch (err) {
595
+ formatError(err instanceof Error ? err.message : String(err), fmt);
596
+ process.exitCode = ExitCode.ERROR;
597
+ }
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // Update internal URLs handler
602
+ // ---------------------------------------------------------------------------
603
+
604
+ async function doUpdateInternalUrls(fmt: FormatOptions): Promise<void> {
605
+ try {
606
+ const config = new ConfigService();
607
+ await config.load();
608
+ const global = config.getGlobal();
609
+ const prefix = global.subdomain_prefix ?? "hoox";
610
+ const workers = config.listEnabledWorkers();
611
+
612
+ // Try pages/dashboard first, fall back to workers/dashboard
613
+ let filePath = resolve(process.cwd(), "pages", "dashboard", "wrangler.jsonc");
614
+ if (!existsSync(filePath)) {
615
+ filePath = resolve(process.cwd(), "workers", "dashboard", "wrangler.jsonc");
616
+ }
617
+ if (!existsSync(filePath)) {
618
+ formatError(new CLIError("Dashboard wrangler.jsonc not found (checked pages/dashboard and workers/dashboard)", ExitCode.ERROR), fmt);
619
+ process.exitCode = ExitCode.ERROR;
620
+ return;
621
+ }
622
+
623
+ const content = readFileSync(filePath, "utf-8");
624
+ const errors: jsonc.ParseError[] = [];
625
+ const parsed = jsonc.parse(content, errors) as Record<string, unknown>;
626
+ if (errors.length > 0) {
627
+ formatError(new CLIError("Invalid JSONC in dashboard wrangler.jsonc", ExitCode.ERROR), fmt);
628
+ process.exitCode = ExitCode.ERROR;
629
+ return;
630
+ }
631
+
632
+ const vars = (parsed.vars as Record<string, string>) ?? {};
633
+ let changesCount = 0;
634
+
635
+ for (const name of workers) {
636
+ const key = `${name.toUpperCase().replace(/-/g, "_")}_URL`;
637
+ const newUrl = `https://${name}.${prefix}.workers.dev`;
638
+ if (vars[key] !== newUrl) {
639
+ if (!fmt.quiet) {
640
+ process.stdout.write(` ${theme.info("→")} ${key}: ${vars[key] ?? "(not set)"} ${theme.dim("→")} ${newUrl}\n`);
641
+ }
642
+ vars[key] = newUrl;
643
+ changesCount++;
644
+ }
645
+ }
646
+
647
+ if (changesCount === 0) {
648
+ formatSuccess("All service URLs already up to date.", fmt);
649
+ return;
650
+ }
651
+
652
+ const edits = jsonc.modify(content, ["vars"], vars, {
653
+ formattingOptions: { tabSize: 2, insertSpaces: true },
654
+ });
655
+ writeFileSync(filePath, jsonc.applyEdits(content, edits), "utf-8");
656
+ formatSuccess(`Updated ${changesCount} service URL(s) in dashboard wrangler.jsonc`, fmt);
657
+ } catch (err) {
658
+ formatError(err instanceof Error ? err.message : String(err), fmt);
659
+ process.exitCode = ExitCode.ERROR;
660
+ }
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // KV config handler
665
+ // ---------------------------------------------------------------------------
666
+
667
+ async function doKvConfig(fmt: FormatOptions): Promise<void> {
668
+ try {
669
+ const { KvSyncService } = await import("../../services/kv/kv-sync-service.js");
670
+ const kvSync = new KvSyncService();
671
+
672
+ process.stdout.write(`${theme.info("Resolving CONFIG_KV namespace...")}\n`);
673
+ const namespaceId = await kvSync.resolveNamespaceId();
674
+ const manifest = KvSyncService.getManifest();
675
+ let setCount = 0;
676
+ let errorCount = 0;
677
+
678
+ for (const entry of manifest.keys) {
679
+ const value = entry.default;
680
+ if (!value || value === "") {
681
+ if (!fmt.quiet) {
682
+ process.stdout.write(` ${theme.dim("·")} ${entry.key} ${theme.dim("(no default, skipping)")}\n`);
683
+ }
684
+ continue;
685
+ }
686
+ try {
687
+ await kvSync.set(namespaceId, entry.key, value);
688
+ if (!fmt.quiet) {
689
+ process.stdout.write(` ${theme.success("✓")} ${entry.key}\n`);
690
+ }
691
+ setCount++;
692
+ } catch (err) {
693
+ process.stdout.write(` ${theme.error("✗")} ${entry.key}: ${err instanceof Error ? err.message : String(err)}\n`);
694
+ errorCount++;
695
+ }
696
+ }
697
+
698
+ process.stdout.write(`\n${setCount} key(s) set, ${errorCount} error(s)\n`);
699
+ if (errorCount > 0) process.exitCode = ExitCode.ERROR;
700
+ } catch (err) {
701
+ formatError(err instanceof Error ? err.message : String(err), fmt);
702
+ process.exitCode = ExitCode.ERROR;
703
+ }
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Command registration
708
+ // ---------------------------------------------------------------------------
709
+
710
+ /**
711
+ * Register the `hoox deploy` command group with subcommands:
712
+ * all, workers, worker <name>, dashboard, telegram-webhook, update-internal-urls, kv-config.
713
+ */
714
+ export function registerDeployCommand(program: Command): void {
715
+ const deployCmd = program
716
+ .command("deploy")
717
+ .summary("Deploy workers and/or dashboard to Cloudflare Workers")
718
+ .description(
719
+ `Deploy your Hoox trading system to Cloudflare's edge network.
720
+
721
+ The deploy command handles building and uploading your workers and dashboard to Cloudflare Workers.
722
+
723
+ DEPLOYMENT ORDER:
724
+ Workers are deployed in the correct order based on their dependencies (e.g., d1-worker before trade-worker).
725
+
726
+ DASHBOARD:
727
+ The dashboard uses OpenNext to convert Next.js to Cloudflare Workers format. Before deploying, the CLI checks for an existing build and prompts you to rebuild or use the existing build.
728
+
729
+ EXAMPLES:
730
+ hoox deploy all Deploy everything (workers + dashboard)
731
+ hoox deploy all --rebuild Force rebuild dashboard before deploying
732
+ hoox deploy workers Deploy workers only (skip dashboard)
733
+ hoox deploy worker trade-worker Deploy a specific worker
734
+ hoox deploy dashboard Deploy dashboard only`
735
+ );
736
+
737
+ // -- deploy all ----------------------------------------------------------
738
+ deployCmd
739
+ .command("all")
740
+ .summary("Deploy all enabled workers, then the dashboard")
741
+ .description(
742
+ `Deploy all enabled workers to Cloudflare Workers, then build and deploy the Next.js dashboard.
743
+
744
+ This is the recommended command for production deployments as it ensures all components are deployed in the correct order.
745
+
746
+ OPTIONS:
747
+ --env <env> Target environment (production, staging, etc.)
748
+ --rebuild Force rebuild of dashboard before deploying (skip prompt)
749
+ --auto Skip dashboard rebuild prompt, use existing build if available
750
+
751
+ EXAMPLES:
752
+ hoox deploy all
753
+ hoox deploy all --env production
754
+ hoox deploy all --rebuild
755
+ hoox deploy all --auto`
756
+ )
757
+ .option("--env <env>", "Cloudflare environment (e.g. production, staging)")
758
+ .option("--rebuild", "Force rebuild of dashboard before deploying")
759
+ .option("--auto", "Skip dashboard rebuild prompt, use existing build if available")
760
+ .action(async (options: { env?: string; rebuild?: boolean; auto?: boolean }) => {
761
+ const fmt = getFormatOptions(program);
762
+ try {
763
+ const configService = new ConfigService();
764
+ await configService.load();
765
+ const cf = new CloudflareService();
766
+
767
+ // Deploy all (workers + dashboard) in one go
768
+ await deployAll(configService, cf, options.env, options.rebuild ?? false, options.auto ?? false);
769
+ } catch (err) {
770
+ const message = err instanceof Error ? err.message : String(err);
771
+ formatError(message, fmt);
772
+ process.exitCode = ExitCode.ERROR;
773
+ }
774
+ });
775
+
776
+ // -- deploy workers ------------------------------------------------------
777
+ deployCmd
778
+ .command("workers")
779
+ .summary("Deploy all enabled workers to Cloudflare")
780
+ .description(
781
+ `Deploy all enabled workers to Cloudflare Workers.
782
+
783
+ Workers are deployed in the correct order based on their dependencies. This command skips the dashboard deployment.
784
+
785
+ OPTIONS:
786
+ --env <env> Target environment (production, staging, etc.)
787
+
788
+ EXAMPLES:
789
+ hoox deploy workers
790
+ hoox deploy workers --env staging`
791
+ )
792
+ .option("--env <env>", "Cloudflare environment (e.g. production, staging)")
793
+ .action(async (options: { env?: string }) => {
794
+ try {
795
+ const configService = new ConfigService();
796
+ await configService.load();
797
+ const cf = new CloudflareService();
798
+
799
+ // Deploy only workers (no dashboard), sorted by dependency order
800
+ const enabled = configService.listEnabledWorkers();
801
+ const workers = DEPLOY_ORDER.filter(
802
+ (w) => w !== "dashboard" && enabled.includes(w)
803
+ );
804
+ const unknown = enabled.filter(
805
+ (w) => w !== "dashboard" && !DEPLOY_ORDER.includes(w)
806
+ );
807
+ const ordered = [...workers, ...unknown];
808
+ const results: DeployResult[] = [];
809
+
810
+ if (ordered.length === 0) {
811
+ process.stdout.write(
812
+ `${theme.dim("No enabled workers to deploy\n")}`
813
+ );
814
+ return;
815
+ }
816
+
817
+ // Print header
818
+ process.stdout.write(`\n${theme.heading("Deploying Workers")}\n`);
819
+ process.stdout.write(`${theme.dim("─".repeat(60))}\n`);
820
+
821
+ for (const name of ordered) {
822
+ process.stdout.write(
823
+ `${theme.dim("○")} ${name.padEnd(25)} pending\n`
824
+ );
825
+ }
826
+ process.stdout.write(`${theme.dim("─".repeat(60))}\n\n`);
827
+
828
+ // Deploy each worker with clack spinner
829
+ for (const name of ordered) {
830
+ const s = spinner();
831
+ s.start(`Deploying ${name}...`);
832
+
833
+ const result = await deploySingle(
834
+ configService,
835
+ cf,
836
+ name,
837
+ options.env
838
+ );
839
+ results.push(result);
840
+
841
+ if (result.success) {
842
+ s.stop(`${theme.success(icons.success)} ${name} deployed`);
843
+ if (result.url)
844
+ process.stdout.write(
845
+ ` ${theme.dim("URL:")} ${result.url}\n`
846
+ );
847
+ if (result.size)
848
+ process.stdout.write(
849
+ ` ${theme.dim("Size:")} ${result.size}\n`
850
+ );
851
+ if (result.startupTime)
852
+ process.stdout.write(
853
+ ` ${theme.dim("Startup:")} ${result.startupTime}\n`
854
+ );
855
+ if (result.versionId)
856
+ process.stdout.write(
857
+ ` ${theme.dim("Version:")} ${result.versionId.slice(0, 8)}...\n`
858
+ );
859
+ if (
860
+ !result.url &&
861
+ !result.size &&
862
+ !result.startupTime &&
863
+ !result.versionId &&
864
+ result.rawOutput
865
+ ) {
866
+ process.stdout.write(
867
+ ` ${theme.dim("Output:")} ${result.rawOutput.slice(0, 80)}\n`
868
+ );
869
+ }
870
+ } else {
871
+ s.stop(`${theme.error(icons.error)} ${name} failed`);
872
+ if (result.error)
873
+ process.stdout.write(
874
+ ` ${theme.error("Error:")} ${result.error}\n`
875
+ );
876
+ }
877
+ }
878
+
879
+ const succeeded = results.filter((r) => r.success).length;
880
+ const failed = results.filter((r) => !r.success).length;
881
+ process.stdout.write(
882
+ `\n${theme.heading("Summary:")} ${succeeded}/${ordered.length} deployed`
883
+ );
884
+ if (failed > 0)
885
+ process.stdout.write(` ${theme.error(`(${failed} failed)`)}\n`);
886
+ else process.stdout.write(` ${theme.success(" ✓")}\n\n`);
887
+ } catch (err) {
888
+ const message = err instanceof Error ? err.message : String(err);
889
+ process.stdout.write(`${theme.error(`Error: ${message}`)}\n`);
890
+ process.exitCode = ExitCode.ERROR;
891
+ }
892
+ });
893
+
894
+ // -- deploy worker <name> ------------------------------------------------
895
+ deployCmd
896
+ .command("worker <name>")
897
+ .summary("Deploy a single worker by name")
898
+ .description(
899
+ `Deploy a specific worker to Cloudflare Workers.
900
+
901
+ ARGUMENTS:
902
+ name Worker name (e.g., trade-worker, agent-worker, hoox)
903
+
904
+ OPTIONS:
905
+ --env <env> Target environment (production, staging, etc.)
906
+
907
+ EXAMPLES:
908
+ hoox deploy worker trade-worker
909
+ hoox deploy worker agent-worker --env production`
910
+ )
911
+ .option("--env <env>", "Cloudflare environment (e.g. production, staging)")
912
+ .action(async (name: string, options: { env?: string }) => {
913
+ const fmt = getFormatOptions(program);
914
+ try {
915
+ const configService = new ConfigService();
916
+ await configService.load();
917
+ const cf = new CloudflareService();
918
+
919
+ const result = await deploySingle(configService, cf, name, options.env);
920
+
921
+ if (result.success) {
922
+ const url = result.url ? ` — ${result.url}` : "";
923
+ formatSuccess(`Deployed ${name}${url}`, fmt);
924
+ } else {
925
+ formatError(
926
+ new CLIError(
927
+ `Failed to deploy "${name}": ${result.error}`,
928
+ ExitCode.ERROR
929
+ ),
930
+ fmt
931
+ );
932
+ process.exitCode = ExitCode.ERROR;
933
+ }
934
+ } catch (err) {
935
+ const message = err instanceof Error ? err.message : String(err);
936
+ formatError(message, fmt);
937
+ process.exitCode = ExitCode.ERROR;
938
+ }
939
+ });
940
+
941
+ // -- deploy dashboard ----------------------------------------------------
942
+ deployCmd
943
+ .command("dashboard")
944
+ .summary("Build and deploy the Next.js dashboard")
945
+ .description(
946
+ `Build and deploy the Hoox dashboard to Cloudflare Workers.
947
+
948
+ The dashboard is built using OpenNext which converts Next.js to Cloudflare Workers format. Before deploying, the CLI checks for an existing build and prompts you to rebuild or use the existing build.
949
+
950
+ OPTIONS:
951
+ --rebuild Force rebuild of dashboard before deploying (skip prompt)
952
+
953
+ EXAMPLES:
954
+ hoox deploy dashboard Interactive: choose to rebuild or use existing
955
+ hoox deploy dashboard --rebuild Force rebuild before deploying`
956
+ )
957
+ .option("--rebuild", "Force rebuild of dashboard before deploying")
958
+ .action(async (options: { rebuild?: boolean }) => {
959
+ const fmt = getFormatOptions(program);
960
+ try {
961
+ const cf = new CloudflareService();
962
+
963
+ const result = await deployDashboard(cf, options.rebuild);
964
+
965
+ if (result.success) {
966
+ const url = result.url ? ` — ${result.url}` : "";
967
+ formatSuccess(`Dashboard deployed${url}`, fmt);
968
+ } else {
969
+ formatError(
970
+ new CLIError(
971
+ `Dashboard deployment failed: ${result.error}`,
972
+ ExitCode.ERROR
973
+ ),
974
+ fmt
975
+ );
976
+ process.exitCode = ExitCode.ERROR;
977
+ }
978
+ } catch (err) {
979
+ const message = err instanceof Error ? err.message : String(err);
980
+ formatError(message, fmt);
981
+ process.exitCode = ExitCode.ERROR;
982
+ }
983
+ });
984
+
985
+ // -- deploy telegram-webhook --------------------------------------------
986
+
987
+ deployCmd
988
+ .command("telegram-webhook")
989
+ .summary("Set Telegram bot webhook (post-deploy step)")
990
+ .description(
991
+ `Configure the Telegram bot webhook after deploying telegram-worker.
992
+
993
+ Calls the Telegram Bot API to set the webhook URL.
994
+
995
+ ARGUMENTS:
996
+ --token <token> Telegram bot token (from @BotFather)
997
+ --secret-token <token> Telegram webhook secret token
998
+ --subdomain <prefix> Worker subdomain prefix (default: from config)
999
+
1000
+ By default, the bot token and secret are read from .env local.
1001
+ Use --token and --secret-token to override.
1002
+
1003
+ EXAMPLES:
1004
+ hoox deploy telegram-webhook
1005
+ hoox deploy telegram-webhook --token 123456:ABC-DEF1234
1006
+ hoox deploy telegram-webhook --subdomain myapp`
1007
+ )
1008
+ .option("--token <token>", "Telegram bot token (from @BotFather)")
1009
+ .option("--secret-token <secret>", "Telegram webhook secret token")
1010
+ .option("--subdomain <prefix>", "Worker subdomain prefix (default: from config)")
1011
+ .action(async (options: { token?: string; secretToken?: string; subdomain?: string }) => {
1012
+ const fmt = getFormatOptions(program);
1013
+ await doTelegramWebhook(fmt, options.token, options.secretToken, options.subdomain);
1014
+ });
1015
+
1016
+ // -- deploy update-internal-urls ---------------------------------------
1017
+
1018
+ deployCmd
1019
+ .command("update-internal-urls")
1020
+ .summary("Update dashboard wrangler.jsonc with current service URLs")
1021
+ .description(
1022
+ `Update the dashboard's wrangler.jsonc with the current service URLs.
1023
+
1024
+ This is a post-deployment step that ensures the dashboard has correct
1025
+ service binding URLs for all workers.
1026
+
1027
+ EXAMPLES:
1028
+ hoox deploy update-internal-urls`
1029
+ )
1030
+ .action(async () => {
1031
+ const fmt = getFormatOptions(program);
1032
+ await doUpdateInternalUrls(fmt);
1033
+ });
1034
+
1035
+ // -- deploy kv-config --------------------------------------------------
1036
+
1037
+ deployCmd
1038
+ .command("kv-config")
1039
+ .summary("Apply KV manifest keys post-deployment")
1040
+ .description(
1041
+ `Apply the KV manifest key-value pairs after deploying workers.
1042
+
1043
+ Sets all KV keys from the manifest to their default values.
1044
+ This post-deployment step initializes the CONFIG_KV namespace.
1045
+
1046
+ EXAMPLES:
1047
+ hoox deploy kv-config`
1048
+ )
1049
+ .action(async () => {
1050
+ const fmt = getFormatOptions(program);
1051
+ await doKvConfig(fmt);
1052
+ });
1053
+ }