@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,627 @@
1
+ /**
2
+ * `hoox init` — interactive setup wizard for Hoox Workspace.
3
+ *
4
+ * Interactive flow:
5
+ * 1. Collect & validate Cloudflare API token
6
+ * 2. Collect account ID
7
+ * 3. Collect secret store ID
8
+ * 4. Select integrations (exchanges, wallet, email, telegram)
9
+ * 5. Collect per-integration secrets
10
+ * 6. Write wrangler.jsonc
11
+ * 7. Create .dev.vars templates
12
+ *
13
+ * Non-interactive mode (--token, --account, --secret-store, --prefix):
14
+ * Skips all prompts and writes config with base workers only.
15
+ */
16
+
17
+ import * as p from "@clack/prompts";
18
+ import { Command } from "commander";
19
+ import { CloudflareService } from "../../services/cloudflare/index.js";
20
+ import { formatSuccess, formatError } from "../../utils/formatters.js";
21
+ import { CLIError, ExitCode } from "../../utils/errors.js";
22
+ import { theme } from "../../utils/theme.js";
23
+ import { INTEGRATIONS, BASE_WORKERS, BASE_SECRETS } from "./types.js";
24
+ import type { InitOptions, WorkersJsonConfig, WorkerConfig } from "./types.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Try to read an existing wrangler.jsonc from the current directory
32
+ * and extract the account ID (used as default value in prompt).
33
+ */
34
+ async function getExistingAccountId(): Promise<string | undefined> {
35
+ try {
36
+ const file = Bun.file("wrangler.jsonc");
37
+ if (!(await file.exists())) return undefined;
38
+ const raw = await file.text();
39
+ // Simple regex extract — avoids depending on jsonc-parser for just this
40
+ const match = raw.match(/"cloudflare_account_id"\s*:\s*"([^"]+)"/);
41
+ return match ? match[1] : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Validate a Cloudflare API token by calling `wrangler whoami`.
49
+ * Returns an error string on failure, undefined on success.
50
+ */
51
+ async function validateApiToken(
52
+ cf: CloudflareService,
53
+ token: string
54
+ ): Promise<string | undefined> {
55
+ // CloudflareService uses wrangler which reads CLOUDFLARE_API_TOKEN from env.
56
+ // We need to pass the token. Temporarily set the env var for the whoami call.
57
+ const prev = process.env.CLOUDFLARE_API_TOKEN;
58
+ process.env.CLOUDFLARE_API_TOKEN = token;
59
+ try {
60
+ const result = await cf.whoami();
61
+ if (result.ok) return undefined;
62
+ return `Authentication failed: ${result.error}`;
63
+ } finally {
64
+ if (prev) {
65
+ process.env.CLOUDFLARE_API_TOKEN = prev;
66
+ } else {
67
+ delete process.env.CLOUDFLARE_API_TOKEN;
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Build the full wrangler.jsonc config object from collected values.
74
+ */
75
+ /**
76
+ * Extended worker config used internally during build to carry collected
77
+ * secret values forward to .dev.vars generation.
78
+ */
79
+ interface InternalWorkerConfig extends WorkerConfig {
80
+ _collectedSecrets?: Record<string, string>;
81
+ _collectedBaseSecrets?: Record<string, string>;
82
+ }
83
+
84
+ function buildConfig(
85
+ globalToken: string,
86
+ globalAccount: string,
87
+ globalSecretStore: string,
88
+ globalPrefix: string,
89
+ selectedIntegrations: string[],
90
+ integrationSecrets: Record<string, Record<string, string>>,
91
+ baseSecrets: Record<string, Record<string, string>>
92
+ ): WorkersJsonConfig {
93
+ const workers: Record<string, InternalWorkerConfig> = {};
94
+
95
+ // Base workers (always enabled)
96
+ for (const [name, baseCfg] of Object.entries(BASE_WORKERS)) {
97
+ const secrets = BASE_SECRETS[name] ? [...BASE_SECRETS[name]] : [];
98
+ workers[name] = {
99
+ enabled: true,
100
+ path: baseCfg.path,
101
+ vars: { ...baseCfg.vars },
102
+ secrets,
103
+ };
104
+ }
105
+
106
+ // Integration workers — merge secrets per worker
107
+ for (const key of selectedIntegrations) {
108
+ const integration = INTEGRATIONS.find((i) => i.key === key);
109
+ if (!integration) continue;
110
+
111
+ const workerName = integration.workerName;
112
+ if (!workers[workerName]) {
113
+ workers[workerName] = {
114
+ enabled: true,
115
+ path: `workers/${workerName}`,
116
+ vars: {},
117
+ secrets: [],
118
+ };
119
+ }
120
+
121
+ // Add integration-specific vars
122
+ if (integration.vars) {
123
+ Object.assign(workers[workerName].vars, integration.vars);
124
+ }
125
+
126
+ // Add integration-specific secrets
127
+ const collected = integrationSecrets[key] ?? {};
128
+ for (const secretName of Object.keys(integration.secrets)) {
129
+ if (!workers[workerName].secrets.includes(secretName)) {
130
+ workers[workerName].secrets.push(secretName);
131
+ }
132
+ }
133
+ // Store collected values for .dev.vars
134
+ if (!workers[workerName]._collectedSecrets) {
135
+ workers[workerName]._collectedSecrets = {};
136
+ }
137
+ Object.assign(workers[workerName]._collectedSecrets!, collected);
138
+ }
139
+
140
+ // Merge base secrets that are collected (per-worker map)
141
+ for (const [wName, secrets] of Object.entries(baseSecrets)) {
142
+ if (workers[wName]) {
143
+ workers[wName]._collectedBaseSecrets = secrets;
144
+ }
145
+ }
146
+
147
+ return {
148
+ global: {
149
+ cloudflare_api_token: globalToken,
150
+ cloudflare_account_id: globalAccount,
151
+ cloudflare_secret_store_id: globalSecretStore,
152
+ subdomain_prefix: globalPrefix,
153
+ },
154
+ workers: workers as Record<string, WorkerConfig>,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Write wrangler.jsonc using JSON-style formatting.
160
+ * Uses JSON.stringify (not jsonc-parser for the main structure,
161
+ * but with a JSONC header comment).
162
+ */
163
+ async function writeWorkersJsonc(
164
+ config: WorkersJsonConfig,
165
+ opts?: { json?: boolean; quiet?: boolean }
166
+ ): Promise<void> {
167
+ const { workers, ...restGlobal } = config;
168
+
169
+ // Strip internal fields before serializing
170
+ const cleanWorkers: Record<string, WorkerConfig> = {};
171
+ for (const [name, worker] of Object.entries(workers)) {
172
+ const w = worker as WorkerConfig & {
173
+ _collectedSecrets?: Record<string, string>;
174
+ _collectedBaseSecrets?: Record<string, string>;
175
+ };
176
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
177
+ const { _collectedSecrets, _collectedBaseSecrets, ...clean } = w;
178
+ cleanWorkers[name] = clean;
179
+ }
180
+
181
+ const out = {
182
+ ...restGlobal,
183
+ workers: cleanWorkers,
184
+ };
185
+
186
+ // Use jsonc-parser's format to get nice JSONC output
187
+ const { format, applyEdits } = await import("jsonc-parser");
188
+
189
+ const raw = JSON.stringify(out, null, 2);
190
+ const formattingOpts = {
191
+ insertSpaces: true,
192
+ tabSize: 2,
193
+ eol: "\n",
194
+ };
195
+ const edits = format(raw, undefined, formattingOpts);
196
+ const formatted = applyEdits(raw, edits);
197
+
198
+ const header =
199
+ "// Hoox Workspace Configuration\n" +
200
+ "// Generated by `hoox init`. Edit manually or re-run the wizard.\n" +
201
+ "// Secrets are referenced by name — actual values are stored in Cloudflare.\n\n";
202
+
203
+ await Bun.write("wrangler.jsonc", header + formatted);
204
+
205
+ if (!opts?.quiet) {
206
+ formatSuccess("wrangler.jsonc written", opts);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Create .dev.vars template files for each worker with collected secrets.
212
+ */
213
+ async function createDevVars(
214
+ config: WorkersJsonConfig,
215
+ integrationSecrets: Record<string, Record<string, string>>,
216
+ baseSecrets: Record<string, Record<string, string>>,
217
+ opts?: { json?: boolean; quiet?: boolean }
218
+ ): Promise<void> {
219
+ for (const [workerName, worker] of Object.entries(config.workers)) {
220
+ const lines: string[] = [];
221
+ lines.push(`# .dev.vars — local secrets for ${workerName}`);
222
+ lines.push(`# Generated by \`hoox init\`. NEVER commit this file.`);
223
+ lines.push("");
224
+
225
+ // Integration secrets for this worker
226
+ for (const integration of INTEGRATIONS) {
227
+ if (integration.workerName === workerName) {
228
+ const collected = integrationSecrets[integration.key];
229
+ if (collected) {
230
+ for (const [key, value] of Object.entries(collected)) {
231
+ lines.push(`${key}=${value}`);
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ // Base secrets for this worker (from direct baseSecrets map)
238
+ const workerBaseSecrets = baseSecrets[workerName];
239
+ if (workerBaseSecrets) {
240
+ for (const [key, value] of Object.entries(workerBaseSecrets)) {
241
+ lines.push(`${key}=${value}`);
242
+ }
243
+ }
244
+
245
+ // Also write secrets from BASE_SECRETS config that the user provided values for
246
+ const collectedBase = (
247
+ worker as unknown as { _collectedBaseSecrets?: Record<string, string> }
248
+ )._collectedBaseSecrets;
249
+ if (collectedBase) {
250
+ for (const [key, value] of Object.entries(collectedBase)) {
251
+ lines.push(`${key}=${value}`);
252
+ }
253
+ }
254
+
255
+ if (lines.length <= 3) continue; // No secrets for this worker
256
+
257
+ const content = lines.join("\n") + "\n";
258
+ const filePath = `${worker.path}/.dev.vars`;
259
+ const dir = `${worker.path}`;
260
+
261
+ // Ensure directory exists
262
+ try {
263
+ await Bun.write(filePath, content);
264
+ if (!opts?.quiet) {
265
+ formatSuccess(`Created ${filePath}`, opts);
266
+ }
267
+ } catch {
268
+ // If directory doesn't exist, create and retry
269
+ const { mkdir } = await import("node:fs/promises");
270
+ await mkdir(dir, { recursive: true });
271
+ await Bun.write(filePath, content);
272
+ if (!opts?.quiet) {
273
+ formatSuccess(`Created ${filePath}`, opts);
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Prompt for per-integration secrets. Returns a map of integration key →
281
+ * collected secret values.
282
+ */
283
+ async function collectIntegrationSecrets(
284
+ selectedIntegrations: string[]
285
+ ): Promise<Record<string, Record<string, string>>> {
286
+ const result: Record<string, Record<string, string>> = {};
287
+
288
+ for (const key of selectedIntegrations) {
289
+ const integration = INTEGRATIONS.find((i) => i.key === key);
290
+ if (!integration || Object.keys(integration.secrets).length === 0) continue;
291
+
292
+ const secretEntries = Object.entries(integration.secrets);
293
+ const groupFields: Record<string, () => Promise<string | symbol>> = {};
294
+
295
+ for (const [secretName] of secretEntries) {
296
+ groupFields[secretName] = () =>
297
+ p.password({
298
+ message: `${integration.secrets[secretName]}:`,
299
+ validate(value) {
300
+ if (!value) return "This secret is required";
301
+ return;
302
+ },
303
+ });
304
+ }
305
+
306
+ const collected = await p.group(groupFields, {
307
+ onCancel: () => {
308
+ p.cancel("Setup cancelled.");
309
+ process.exit(0);
310
+ },
311
+ });
312
+
313
+ // Convert Symbol values (shouldn't happen due to onCancel) to empty strings
314
+ result[key] = {};
315
+ for (const [secretName] of secretEntries) {
316
+ const val = collected[secretName];
317
+ result[key][secretName] = typeof val === "string" ? val : "";
318
+ }
319
+ }
320
+
321
+ return result;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Main command registration
326
+ // ---------------------------------------------------------------------------
327
+
328
+ export function registerInitCommand(program: Command): void {
329
+ program
330
+ .command("init")
331
+ .summary("Interactive setup wizard for Hoox Workspace")
332
+ .description(
333
+ `Initialize a new Hoox workspace with interactive prompts.
334
+
335
+ This wizard helps you set up:
336
+ - Cloudflare API token and account
337
+ - Subdomain prefix for worker URLs
338
+ - Secret store configuration
339
+ - Initial worker configuration
340
+
341
+ INTERACTIVE MODE:
342
+ Run without flags to use the interactive wizard.
343
+
344
+ NON-INTERACTIVE MODE:
345
+ Provide all required options to skip prompts.
346
+
347
+ OPTIONS:
348
+ --token <token> Cloudflare API token
349
+ --account <id> Cloudflare Account ID
350
+ --secret-store <id> Secret Store ID
351
+ --prefix <prefix> Subdomain prefix (default: cryptolinx)
352
+ --accept-risk Skip risk acknowledgment
353
+
354
+ EXAMPLES:
355
+ hoox init Interactive wizard
356
+ hoox init --token cfut_xxx --account xxx Non-interactive`
357
+ )
358
+ .option("--token <token>", "Cloudflare API token (non-interactive)")
359
+ .option("--account <id>", "Cloudflare Account ID (non-interactive)")
360
+ .option("--secret-store <id>", "Secret Store ID (non-interactive)")
361
+ .option(
362
+ "--prefix <prefix>",
363
+ "Subdomain prefix (non-interactive, default: cryptolinx)"
364
+ )
365
+ .option("--accept-risk", "Skip the risk acknowledgment confirmation")
366
+ .action(async (options: InitOptions) => {
367
+ const globalOpts = program.opts() as { json?: boolean; quiet?: boolean };
368
+ const isNonInteractive = Boolean(options.token && options.account);
369
+
370
+ try {
371
+ await runInitCommand(options, globalOpts, isNonInteractive);
372
+ } catch (err) {
373
+ const message = err instanceof Error ? err.message : String(err);
374
+ p.log.error(message);
375
+ formatError(new CLIError(message, ExitCode.ERROR), globalOpts);
376
+ process.exit(ExitCode.ERROR);
377
+ }
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Core logic extracted for testability.
383
+ */
384
+ export async function runInitCommand(
385
+ options: InitOptions,
386
+ globalOpts: { json?: boolean; quiet?: boolean },
387
+ isNonInteractive: boolean
388
+ ): Promise<void> {
389
+ // ------------------------------------------------------------------
390
+ // Non-interactive mode: write config directly from flags
391
+ // ------------------------------------------------------------------
392
+ if (isNonInteractive) {
393
+ if (!globalOpts.quiet) {
394
+ p.intro("Hoox Setup Wizard");
395
+ p.note("Non-interactive mode — using provided flags.", "Mode");
396
+ }
397
+
398
+ const token = options.token!;
399
+ const account = options.account!;
400
+ const secretStore = options.secretStore ?? "";
401
+ const prefix = options.prefix ?? "cryptolinx";
402
+
403
+ // Validate token
404
+ const cf = new CloudflareService();
405
+ const error = await validateApiToken(cf, token);
406
+ if (error) {
407
+ formatError(new CLIError(error, ExitCode.ERROR), globalOpts);
408
+ process.exit(ExitCode.ERROR);
409
+ }
410
+ if (!globalOpts.quiet) {
411
+ formatSuccess("Cloudflare API token validated", globalOpts);
412
+ }
413
+
414
+ const config = buildConfig(
415
+ token,
416
+ account,
417
+ secretStore,
418
+ prefix,
419
+ [], // no integrations in non-interactive mode
420
+ {},
421
+ {}
422
+ );
423
+
424
+ await writeWorkersJsonc(config, globalOpts);
425
+ await createDevVars(config, {}, {}, globalOpts);
426
+
427
+ if (!globalOpts.quiet) {
428
+ p.outro("Setup complete! Run hoox check setup to verify.");
429
+ }
430
+ return;
431
+ }
432
+
433
+ // ------------------------------------------------------------------
434
+ // Interactive mode
435
+ // ------------------------------------------------------------------
436
+
437
+ p.intro(theme.heading("Hoox Setup Wizard"));
438
+
439
+ const skipRiskWarning = options.acceptRisk || isNonInteractive;
440
+ if (!skipRiskWarning) {
441
+ const accepted = await p.confirm({
442
+ message:
443
+ "Hoox connects to live trading exchanges and can execute real trades with real money. " +
444
+ "By continuing, you acknowledge that you are solely responsible for all trading activity " +
445
+ "and accept the risk of financial loss. Do you accept these terms?",
446
+ initialValue: false,
447
+ });
448
+
449
+ if (p.isCancel(accepted)) {
450
+ p.cancel("Setup cancelled.");
451
+ process.exit(0);
452
+ }
453
+
454
+ if (!accepted) {
455
+ p.outro("Setup cancelled. See DISCLAIMER.md for full terms.");
456
+ process.exit(0);
457
+ }
458
+ }
459
+
460
+ // Step 1: Cloudflare API token with validation loop
461
+ const cf = new CloudflareService();
462
+ let apiToken: string | symbol = "";
463
+ let tokenValid = false;
464
+
465
+ while (!tokenValid) {
466
+ apiToken = await p.password({
467
+ message: "Cloudflare API token:",
468
+ validate(value) {
469
+ if (!value) return "API token is required";
470
+ return;
471
+ },
472
+ });
473
+
474
+ if (p.isCancel(apiToken)) {
475
+ p.cancel("Setup cancelled.");
476
+ process.exit(0);
477
+ }
478
+
479
+ p.log.step("Validating Cloudflare API token...");
480
+ const tokenError = await validateApiToken(cf, apiToken as string);
481
+ if (tokenError) {
482
+ p.log.error(tokenError);
483
+ p.log.warn("Please check your token and try again.");
484
+ } else {
485
+ tokenValid = true;
486
+ formatSuccess("Cloudflare API token validated", globalOpts);
487
+ }
488
+ }
489
+
490
+ // Step 2: Account ID
491
+ const defaultAccountId = await getExistingAccountId();
492
+ const accountResult = await p.text({
493
+ message: "Cloudflare Account ID:",
494
+ placeholder: "abc123...",
495
+ defaultValue: defaultAccountId ?? "",
496
+ validate(value) {
497
+ if (!value) return "Account ID is required";
498
+ return;
499
+ },
500
+ });
501
+ if (p.isCancel(accountResult)) {
502
+ p.cancel("Setup cancelled.");
503
+ process.exit(0);
504
+ }
505
+ const accountId: string = accountResult;
506
+
507
+ // Step 3: Secret Store ID
508
+ const secretStoreResult = await p.text({
509
+ message: "Cloudflare Secret Store ID:",
510
+ placeholder: "optional",
511
+ defaultValue: "",
512
+ });
513
+ if (p.isCancel(secretStoreResult)) {
514
+ p.cancel("Setup cancelled.");
515
+ process.exit(0);
516
+ }
517
+ const secretStoreId: string = secretStoreResult;
518
+
519
+ // Step 4: Subdomain prefix
520
+ const prefixResult = await p.text({
521
+ message: "Subdomain prefix:",
522
+ placeholder: "cryptolinx",
523
+ defaultValue: "cryptolinx",
524
+ validate(value) {
525
+ if (!value) return "Subdomain prefix is required";
526
+ return;
527
+ },
528
+ });
529
+ if (p.isCancel(prefixResult)) {
530
+ p.cancel("Setup cancelled.");
531
+ process.exit(0);
532
+ }
533
+ const prefix: string = prefixResult;
534
+
535
+ // Step 5: Select integrations
536
+ const integrationOptions = INTEGRATIONS.map((i) => ({
537
+ value: i.key,
538
+ label: i.label,
539
+ hint: i.workerName,
540
+ }));
541
+
542
+ const selectedIntegrations = await p.multiselect({
543
+ message: "Select integrations to enable:",
544
+ options: integrationOptions,
545
+ required: false,
546
+ });
547
+
548
+ if (p.isCancel(selectedIntegrations)) {
549
+ p.cancel("Setup cancelled.");
550
+ process.exit(0);
551
+ }
552
+
553
+ const selected: string[] = Array.isArray(selectedIntegrations)
554
+ ? selectedIntegrations
555
+ : [];
556
+
557
+ // Step 6: Collect per-integration secrets
558
+ p.log.step("Collecting integration secrets...");
559
+ const integrationSecrets = await collectIntegrationSecrets(selected);
560
+
561
+ // Step 7: Collect base secrets for always-enabled workers
562
+ const baseSecrets: Record<string, Record<string, string>> = {};
563
+
564
+ const collectBaseSecrets =
565
+ selected.length > 0 ||
566
+ (await p.confirm({
567
+ message: "Configure base worker secrets?",
568
+ initialValue: true,
569
+ }));
570
+
571
+ if (collectBaseSecrets) {
572
+ for (const [workerName, secretNames] of Object.entries(BASE_SECRETS)) {
573
+ if (secretNames.length === 0) continue;
574
+
575
+ const groupFields: Record<string, () => Promise<string | symbol>> = {};
576
+ for (const secretName of secretNames) {
577
+ groupFields[secretName] = () =>
578
+ p.password({
579
+ message: `${workerName} — ${secretName}:`,
580
+ validate(value) {
581
+ if (!value) return `Required for ${workerName}`;
582
+ return;
583
+ },
584
+ });
585
+ }
586
+
587
+ p.log.step(`Secrets for ${workerName}...`);
588
+ const collected = await p.group(groupFields, {
589
+ onCancel: () => {
590
+ p.cancel("Setup cancelled.");
591
+ process.exit(0);
592
+ },
593
+ });
594
+
595
+ baseSecrets[workerName] = {};
596
+ for (const secretName of secretNames) {
597
+ const val = collected[secretName];
598
+ if (typeof val === "string") {
599
+ baseSecrets[workerName][secretName] = val;
600
+ }
601
+ }
602
+ }
603
+ }
604
+
605
+ // Step 8: Build and write config
606
+ p.log.step("Writing configuration...");
607
+
608
+ const config = buildConfig(
609
+ apiToken as string,
610
+ accountId,
611
+ secretStoreId,
612
+ prefix,
613
+ selected,
614
+ integrationSecrets,
615
+ baseSecrets
616
+ );
617
+
618
+ await writeWorkersJsonc(config, globalOpts);
619
+ await createDevVars(config, integrationSecrets, baseSecrets, globalOpts);
620
+
621
+ // Done
622
+ p.outro(
623
+ theme.success("Setup complete! Run ") +
624
+ theme.bold("hoox check setup") +
625
+ theme.success(" to verify.")
626
+ );
627
+ }