@jango-blockchained/hoox-cli 0.4.1 → 0.5.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.
@@ -1,42 +1,38 @@
1
1
  /**
2
2
  * `hoox init` — interactive setup wizard for Hoox Workspace.
3
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
4
+ * Uses the shared WizardEngine from @jango-blockchained/hoox-shared.
12
5
  *
6
+ * Interactive flow uses @clack/prompts for the UI layer.
13
7
  * Non-interactive mode (--token, --account, --secret-store, --prefix):
14
8
  * Skips all prompts and writes config with base workers only.
15
9
  */
16
10
 
17
11
  import * as p from "@clack/prompts";
18
12
  import { Command } from "commander";
13
+ import {
14
+ WizardEngine,
15
+ serializeState,
16
+ deserializeState,
17
+ WIZARD_STATE_PATH,
18
+ } from "@jango-blockchained/hoox-shared";
19
+ import type { WorkersJsonConfig } from "@jango-blockchained/hoox-shared";
19
20
  import { CloudflareService } from "../../services/cloudflare/index.js";
20
21
  import { formatSuccess, formatError } from "../../utils/formatters.js";
21
22
  import { CLIError, ExitCode } from "../../utils/errors.js";
22
23
  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";
24
+ import { CLIProvisioner } from "./cli-provisioner.js";
25
+ import type { InitOptions } from "./types.js";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Helpers
28
29
  // ---------------------------------------------------------------------------
29
30
 
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
31
  async function getExistingAccountId(): Promise<string | undefined> {
35
32
  try {
36
33
  const file = Bun.file("wrangler.jsonc");
37
34
  if (!(await file.exists())) return undefined;
38
35
  const raw = await file.text();
39
- // Simple regex extract — avoids depending on jsonc-parser for just this
40
36
  const match = raw.match(/"cloudflare_account_id"\s*:\s*"([^"]+)"/);
41
37
  return match ? match[1] : undefined;
42
38
  } catch {
@@ -44,16 +40,10 @@ async function getExistingAccountId(): Promise<string | undefined> {
44
40
  }
45
41
  }
46
42
 
47
- /**
48
- * Validate a Cloudflare API token by calling `wrangler whoami`.
49
- * Returns an error string on failure, undefined on success.
50
- */
51
43
  async function validateApiToken(
52
44
  cf: CloudflareService,
53
45
  token: string
54
46
  ): 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
47
  const prev = process.env.CLOUDFLARE_API_TOKEN;
58
48
  process.env.CLOUDFLARE_API_TOKEN = token;
59
49
  try {
@@ -69,124 +59,13 @@ async function validateApiToken(
69
59
  }
70
60
  }
71
61
 
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
62
  async function writeWorkersJsonc(
164
63
  config: WorkersJsonConfig,
165
64
  opts?: { json?: boolean; quiet?: boolean }
166
65
  ): 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
66
  const { format, applyEdits } = await import("jsonc-parser");
188
67
 
189
- const raw = JSON.stringify(out, null, 2);
68
+ const raw = JSON.stringify(config, null, 2);
190
69
  const formattingOpts = {
191
70
  insertSpaces: true,
192
71
  tabSize: 2,
@@ -207,13 +86,9 @@ async function writeWorkersJsonc(
207
86
  }
208
87
  }
209
88
 
210
- /**
211
- * Create .dev.vars template files for each worker with collected secrets.
212
- */
213
89
  async function createDevVars(
214
90
  config: WorkersJsonConfig,
215
- integrationSecrets: Record<string, Record<string, string>>,
216
- baseSecrets: Record<string, Record<string, string>>,
91
+ secrets: Record<string, Record<string, string>>,
217
92
  opts?: { json?: boolean; quiet?: boolean }
218
93
  ): Promise<void> {
219
94
  for (const [workerName, worker] of Object.entries(config.workers)) {
@@ -222,50 +97,25 @@ async function createDevVars(
222
97
  lines.push(`# Generated by \`hoox init\`. NEVER commit this file.`);
223
98
  lines.push("");
224
99
 
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)) {
100
+ // Add secrets from the collected data
101
+ for (const [, integrationSecrets] of Object.entries(secrets)) {
102
+ for (const [key, value] of Object.entries(integrationSecrets)) {
241
103
  lines.push(`${key}=${value}`);
242
104
  }
243
105
  }
244
106
 
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
107
+ if (lines.length <= 3) continue;
256
108
 
257
109
  const content = lines.join("\n") + "\n";
258
110
  const filePath = `${worker.path}/.dev.vars`;
259
111
  const dir = `${worker.path}`;
260
112
 
261
- // Ensure directory exists
262
113
  try {
263
114
  await Bun.write(filePath, content);
264
115
  if (!opts?.quiet) {
265
116
  formatSuccess(`Created ${filePath}`, opts);
266
117
  }
267
118
  } catch {
268
- // If directory doesn't exist, create and retry
269
119
  const { mkdir } = await import("node:fs/promises");
270
120
  await mkdir(dir, { recursive: true });
271
121
  await Bun.write(filePath, content);
@@ -276,49 +126,33 @@ async function createDevVars(
276
126
  }
277
127
  }
278
128
 
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
- }
129
+ async function saveState(engine: WizardEngine): Promise<void> {
130
+ const state = engine.getState();
131
+ const json = serializeState(state);
132
+ await Bun.write(WIZARD_STATE_PATH, json);
133
+ }
305
134
 
306
- const collected = await p.group(groupFields, {
307
- onCancel: () => {
308
- p.cancel("Setup cancelled.");
309
- process.exit(0);
310
- },
311
- });
135
+ async function loadSavedState(): Promise<WizardEngine | null> {
136
+ try {
137
+ const file = Bun.file(WIZARD_STATE_PATH);
138
+ if (!(await file.exists())) return null;
139
+ const json = await file.text();
140
+ const state = deserializeState(json);
141
+ return new WizardEngine(state);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
312
146
 
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 : "";
147
+ async function cleanupState(): Promise<void> {
148
+ try {
149
+ const file = Bun.file(WIZARD_STATE_PATH);
150
+ if (await file.exists()) {
151
+ await Bun.write(WIZARD_STATE_PATH, "");
318
152
  }
153
+ } catch {
154
+ // ignore
319
155
  }
320
-
321
- return result;
322
156
  }
323
157
 
324
158
  // ---------------------------------------------------------------------------
@@ -336,6 +170,8 @@ This wizard helps you set up:
336
170
  - Cloudflare API token and account
337
171
  - Subdomain prefix for worker URLs
338
172
  - Secret store configuration
173
+ - Worker selection with preset templates
174
+ - Infrastructure provisioning (D1, KV)
339
175
  - Initial worker configuration
340
176
 
341
177
  INTERACTIVE MODE:
@@ -349,6 +185,8 @@ OPTIONS:
349
185
  --account <id> Cloudflare Account ID
350
186
  --secret-store <id> Secret Store ID
351
187
  --prefix <prefix> Subdomain prefix (default: cryptolinx)
188
+ --preset <name> Worker preset (minimal, standard, full)
189
+ --resume Resume from saved wizard state
352
190
  --accept-risk Skip risk acknowledgment
353
191
 
354
192
  EXAMPLES:
@@ -362,6 +200,8 @@ EXAMPLES:
362
200
  "--prefix <prefix>",
363
201
  "Subdomain prefix (non-interactive, default: cryptolinx)"
364
202
  )
203
+ .option("--preset <name>", "Worker preset (minimal, standard, full)")
204
+ .option("--resume", "Resume from saved wizard state")
365
205
  .option("--accept-risk", "Skip the risk acknowledgment confirmation")
366
206
  .action(async (options: InitOptions) => {
367
207
  const globalOpts = program.opts() as { json?: boolean; quiet?: boolean };
@@ -378,17 +218,16 @@ EXAMPLES:
378
218
  });
379
219
  }
380
220
 
381
- /**
382
- * Core logic extracted for testability.
383
- */
221
+ // ---------------------------------------------------------------------------
222
+ // Core logic
223
+ // ---------------------------------------------------------------------------
224
+
384
225
  export async function runInitCommand(
385
226
  options: InitOptions,
386
227
  globalOpts: { json?: boolean; quiet?: boolean },
387
228
  isNonInteractive: boolean
388
229
  ): Promise<void> {
389
- // ------------------------------------------------------------------
390
- // Non-interactive mode: write config directly from flags
391
- // ------------------------------------------------------------------
230
+ // ── Non-interactive mode ───────────────────────────────────────────────
392
231
  if (isNonInteractive) {
393
232
  if (!globalOpts.quiet) {
394
233
  p.intro("Hoox Setup Wizard");
@@ -411,18 +250,29 @@ export async function runInitCommand(
411
250
  formatSuccess("Cloudflare API token validated", globalOpts);
412
251
  }
413
252
 
414
- const config = buildConfig(
415
- token,
416
- account,
417
- secretStore,
418
- prefix,
419
- [], // no integrations in non-interactive mode
420
- {},
421
- {}
422
- );
253
+ // Use engine to build config
254
+ const engine = new WizardEngine();
255
+ engine.execute({ checksPassed: true });
256
+ engine.execute({
257
+ apiToken: token,
258
+ accountId: account,
259
+ secretStoreId: secretStore,
260
+ subdomain: prefix,
261
+ });
262
+
263
+ // Apply preset if provided
264
+ if (
265
+ options.preset &&
266
+ ["minimal", "standard", "full"].includes(options.preset)
267
+ ) {
268
+ engine.execute({ preset: options.preset });
269
+ } else {
270
+ engine.execute({ preset: "minimal" as const });
271
+ }
423
272
 
273
+ const config = engine.buildConfig();
424
274
  await writeWorkersJsonc(config, globalOpts);
425
- await createDevVars(config, {}, {}, globalOpts);
275
+ await createDevVars(config, {}, globalOpts);
426
276
 
427
277
  if (!globalOpts.quiet) {
428
278
  p.outro("Setup complete! Run hoox check setup to verify.");
@@ -430,14 +280,36 @@ export async function runInitCommand(
430
280
  return;
431
281
  }
432
282
 
433
- // ------------------------------------------------------------------
434
- // Interactive mode
435
- // ------------------------------------------------------------------
283
+ // ── Interactive mode ─────────────────────────────────────────────────
436
284
 
437
285
  p.intro(theme.heading("Hoox Setup Wizard"));
438
286
 
439
- const skipRiskWarning = options.acceptRisk || isNonInteractive;
440
- if (!skipRiskWarning) {
287
+ // Try to resume from saved state
288
+ let engine: WizardEngine | null = null;
289
+ if (options.resume) {
290
+ engine = await loadSavedState();
291
+ if (engine) {
292
+ const resumeStep = engine.getCurrentStep().label;
293
+ const resumed = await p.confirm({
294
+ message: `Found saved wizard state. Resume from "${resumeStep}"?`,
295
+ initialValue: true,
296
+ });
297
+ if (p.isCancel(resumed)) {
298
+ p.cancel("Setup cancelled.");
299
+ process.exit(0);
300
+ }
301
+ if (!resumed) {
302
+ engine = null;
303
+ }
304
+ }
305
+ }
306
+
307
+ if (!engine) {
308
+ engine = new WizardEngine();
309
+ }
310
+
311
+ const skipRiskWarning = options.acceptRisk;
312
+ if (!skipRiskWarning && engine.getCurrentStep().id === "PREREQUISITES") {
441
313
  const accepted = await p.confirm({
442
314
  message:
443
315
  "Hoox connects to live trading exchanges and can execute real trades with real money. " +
@@ -455,170 +327,264 @@ export async function runInitCommand(
455
327
  p.outro("Setup cancelled. See DISCLAIMER.md for full terms.");
456
328
  process.exit(0);
457
329
  }
330
+
331
+ // Step 0: Prerequisites check (always passes — CLI is running)
332
+ engine.execute({ checksPassed: true });
333
+ await saveState(engine);
458
334
  }
459
335
 
460
- // Step 1: Cloudflare API token with validation loop
461
- const cf = new CloudflareService();
462
- let apiToken: string | symbol = "";
463
- let tokenValid = false;
336
+ // ── Step 1: Cloudflare API token ──────────────────────────────────────
337
+ if (engine.getCurrentStep().id === "CLOUDFLARE_CONFIG") {
338
+ const cf = new CloudflareService();
339
+ let apiToken: string | symbol = "";
340
+ let tokenValid = false;
341
+
342
+ while (!tokenValid) {
343
+ apiToken = await p.password({
344
+ message: "Cloudflare API token:",
345
+ validate(value) {
346
+ if (!value) return "API token is required";
347
+ return;
348
+ },
349
+ });
350
+
351
+ if (p.isCancel(apiToken)) {
352
+ p.cancel("Setup cancelled.");
353
+ process.exit(0);
354
+ }
355
+
356
+ p.log.step("Validating Cloudflare API token...");
357
+ const tokenError = await validateApiToken(cf, apiToken as string);
358
+ if (tokenError) {
359
+ p.log.error(tokenError);
360
+ p.log.warn("Please check your token and try again.");
361
+ } else {
362
+ tokenValid = true;
363
+ formatSuccess("Cloudflare API token validated", globalOpts);
364
+ }
365
+ }
464
366
 
465
- while (!tokenValid) {
466
- apiToken = await p.password({
467
- message: "Cloudflare API token:",
367
+ const defaultAccountId = await getExistingAccountId();
368
+ const accountResult = await p.text({
369
+ message: "Cloudflare Account ID:",
370
+ placeholder: "abc123...",
371
+ defaultValue: defaultAccountId ?? "",
468
372
  validate(value) {
469
- if (!value) return "API token is required";
373
+ if (!value) return "Account ID is required";
374
+ if (!/^[a-f0-9]{32}$/i.test(value.trim())) {
375
+ return "Account ID should be a 32-character hex string";
376
+ }
470
377
  return;
471
378
  },
472
379
  });
380
+ if (p.isCancel(accountResult)) {
381
+ p.cancel("Setup cancelled.");
382
+ process.exit(0);
383
+ }
473
384
 
474
- if (p.isCancel(apiToken)) {
385
+ const secretStoreResult = await p.text({
386
+ message: "Cloudflare Secret Store ID:",
387
+ placeholder: "optional",
388
+ defaultValue: "",
389
+ });
390
+ if (p.isCancel(secretStoreResult)) {
475
391
  p.cancel("Setup cancelled.");
476
392
  process.exit(0);
477
393
  }
478
394
 
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);
395
+ const prefixResult = await p.text({
396
+ message: "Subdomain prefix:",
397
+ placeholder: "cryptolinx",
398
+ defaultValue: "cryptolinx",
399
+ validate(value) {
400
+ if (!value) return "Subdomain prefix is required";
401
+ return;
402
+ },
403
+ });
404
+ if (p.isCancel(prefixResult)) {
405
+ p.cancel("Setup cancelled.");
406
+ process.exit(0);
487
407
  }
488
- }
489
408
 
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);
409
+ engine.execute({
410
+ apiToken: apiToken as string,
411
+ accountId: accountResult,
412
+ secretStoreId: secretStoreResult,
413
+ subdomain: prefixResult,
414
+ });
415
+ await saveState(engine);
532
416
  }
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);
417
+
418
+ // ── Step 2: Worker selection with presets ──────────────────────────────
419
+ if (engine.getCurrentStep().id === "WORKER_SELECTION") {
420
+ const presetChoice = await p.select({
421
+ message: "Select a worker preset:",
422
+ options: [
423
+ {
424
+ value: "minimal",
425
+ label: "Minimal",
426
+ hint: "Gateway + D1 — webhook processing only",
427
+ },
428
+ {
429
+ value: "standard",
430
+ label: "Standard",
431
+ hint: "Trading + analytics + Telegram",
432
+ },
433
+ {
434
+ value: "full",
435
+ label: "Full",
436
+ hint: "All workers + AI + DeFi + email",
437
+ },
438
+ ],
439
+ });
440
+
441
+ if (p.isCancel(presetChoice)) {
442
+ p.cancel("Setup cancelled.");
443
+ process.exit(0);
444
+ }
445
+
446
+ engine.execute({ preset: presetChoice as string });
447
+ await saveState(engine);
448
+
449
+ // Show selected workers
450
+ const state = engine.getState();
451
+ p.log.step(`Selected workers: ${state.selectedWorkers.join(", ")}`);
452
+ if (state.selectedIntegrations.length > 0) {
453
+ p.log.step(`Integrations: ${state.selectedIntegrations.join(", ")}`);
454
+ }
551
455
  }
552
456
 
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
- });
457
+ // ── Step 3: Provisioning ──────────────────────────────────────────────
458
+ if (engine.getCurrentStep().id === "PROVISIONING") {
459
+ const plan = engine.getProvisioningPlan();
460
+ const hasResources =
461
+ plan.d1Databases.length > 0 || plan.kvNamespaces.length > 0;
462
+
463
+ if (hasResources) {
464
+ const shouldProvision = await p.confirm({
465
+ message: `Provision Cloudflare resources? (${[
466
+ ...plan.d1Databases.map((d) => `D1:${d}`),
467
+ ...plan.kvNamespaces.map((k) => `KV:${k}`),
468
+ ].join(", ")})`,
469
+ initialValue: true,
470
+ });
471
+
472
+ if (p.isCancel(shouldProvision)) {
473
+ p.cancel("Setup cancelled.");
474
+ process.exit(0);
585
475
  }
586
476
 
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
- });
477
+ if (shouldProvision) {
478
+ p.log.step("Provisioning infrastructure...");
479
+ const provisioner = new CLIProvisioner();
480
+ const result = await provisioner.provision(plan);
594
481
 
595
- baseSecrets[workerName] = {};
596
- for (const secretName of secretNames) {
597
- const val = collected[secretName];
598
- if (typeof val === "string") {
599
- baseSecrets[workerName][secretName] = val;
482
+ if (result.success) {
483
+ formatSuccess(`Created: ${result.created.join(", ")}`, globalOpts);
484
+ } else {
485
+ p.log.warn(`Provisioning had issues: ${result.errors.join("; ")}`);
600
486
  }
487
+
488
+ engine.execute({ provisioningResults: result });
489
+ } else {
490
+ engine.execute({
491
+ provisioningResults: { success: true, created: [], errors: [] },
492
+ });
601
493
  }
494
+ } else {
495
+ engine.execute({});
602
496
  }
497
+ await saveState(engine);
603
498
  }
604
499
 
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
- );
500
+ // ── Step 4: Secrets ──────────────────────────────────────────────────
501
+ if (engine.getCurrentStep().id === "SECRETS") {
502
+ const state = engine.getState();
503
+ const collectedSecrets: Record<string, Record<string, string>> = {};
504
+
505
+ if (state.selectedIntegrations.length > 0) {
506
+ p.log.step("Collecting integration secrets...");
507
+
508
+ for (const key of state.selectedIntegrations) {
509
+ const { INTEGRATIONS } =
510
+ await import("@jango-blockchained/hoox-shared");
511
+ const integration = INTEGRATIONS.find((i) => i.key === key);
512
+ if (!integration || Object.keys(integration.secrets).length === 0)
513
+ continue;
514
+
515
+ const secretEntries = Object.entries(integration.secrets);
516
+ const groupFields: Record<string, () => Promise<string | symbol>> = {};
517
+
518
+ for (const [secretName] of secretEntries) {
519
+ groupFields[secretName] = () =>
520
+ p.password({
521
+ message: `${integration.secrets[secretName]}:`,
522
+ validate(value) {
523
+ if (!value) return "This secret is required";
524
+ return;
525
+ },
526
+ });
527
+ }
528
+
529
+ const collected = await p.group(groupFields, {
530
+ onCancel: () => {
531
+ p.cancel("Setup cancelled.");
532
+ process.exit(0);
533
+ },
534
+ });
535
+
536
+ collectedSecrets[key] = {};
537
+ for (const [secretName] of secretEntries) {
538
+ const val = collected[secretName];
539
+ collectedSecrets[key][secretName] =
540
+ typeof val === "string" ? val : "";
541
+ }
542
+ }
543
+ }
617
544
 
618
- await writeWorkersJsonc(config, globalOpts);
619
- await createDevVars(config, integrationSecrets, baseSecrets, globalOpts);
545
+ engine.execute({ secrets: collectedSecrets });
546
+ await saveState(engine);
547
+ }
548
+
549
+ // ── Step 5: Config Write ──────────────────────────────────────────────
550
+ if (engine.getCurrentStep().id === "CONFIG_WRITE") {
551
+ p.log.step("Writing configuration...");
552
+
553
+ const config = engine.buildConfig();
554
+ const state = engine.getState();
555
+
556
+ await writeWorkersJsonc(config, globalOpts);
557
+ await createDevVars(config, state.secrets, globalOpts);
558
+
559
+ engine.execute({});
560
+ await cleanupState(); // Clean up state file — wizard complete
561
+ }
562
+
563
+ // ── Step 6: Deploy ──────────────────────────────────────────────────
564
+ if (engine.getCurrentStep().id === "DEPLOY") {
565
+ const shouldDeploy = await p.confirm({
566
+ message: "Deploy workers now?",
567
+ initialValue: false,
568
+ });
569
+
570
+ if (p.isCancel(shouldDeploy)) {
571
+ p.cancel("Setup cancelled.");
572
+ process.exit(0);
573
+ }
574
+
575
+ if (shouldDeploy) {
576
+ p.log.step("Deploying workers...");
577
+ const proc = Bun.spawn(["hoox", "deploy", "all"], {
578
+ stdout: "inherit",
579
+ stderr: "inherit",
580
+ });
581
+ await proc.exited;
582
+ }
583
+
584
+ engine.execute({});
585
+ }
620
586
 
621
- // Done
587
+ // ── Done ──────────────────────────────────────────────────────────────
622
588
  p.outro(
623
589
  theme.success("Setup complete! Run ") +
624
590
  theme.bold("hoox check setup") +