@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,901 @@
1
+ /**
2
+ * `hoox config` command group — configuration and secrets management.
3
+ *
4
+ * Subcommands:
5
+ * show Display current wrangler.jsonc config
6
+ * set <k> <v> Update a config value in wrangler.jsonc
7
+ * secrets ... Manage Cloudflare secrets (list, set, delete, sync)
8
+ * keys ... Manage internal auth keys (generate, list)
9
+ */
10
+ import { existsSync, mkdirSync } from "node:fs";
11
+
12
+ import { Command } from "commander";
13
+ import { modify, applyEdits } from "jsonc-parser";
14
+ import type { FormattingOptions } from "jsonc-parser";
15
+ import { spinner } from "@clack/prompts";
16
+
17
+ import { ConfigService } from "../../services/config/index.js";
18
+ import { SecretsService } from "../../services/secrets/index.js";
19
+ import { registerEnvCommand } from "./env-command.js";
20
+ import { registerKvCommand } from "./kv-command.js";
21
+ import { CLIError, ExitCode } from "../../utils/errors.js";
22
+ import {
23
+ formatSuccess,
24
+ formatError,
25
+ formatTable,
26
+ formatKeyValue,
27
+ formatJson,
28
+ } from "../../utils/formatters.js";
29
+ import type { FormatOptions } from "../../utils/formatters.js";
30
+ import { theme, icons } from "../../utils/theme.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const FORMATTING_OPTIONS: FormattingOptions = {
37
+ tabSize: 2,
38
+ insertSpaces: true,
39
+ eol: "\n",
40
+ };
41
+
42
+ /**
43
+ * Convert a dot-separated user key path (e.g. "workers.d1-worker.vars.db")
44
+ * into a jsonc-parser `JSONPath` array `["workers","d1-worker","vars","db"]`.
45
+ */
46
+ function keyToPath(key: string): (string | number)[] {
47
+ return key.split(".");
48
+ }
49
+
50
+ /**
51
+ * Read the raw content of wrangler.jsonc.
52
+ */
53
+ async function readConfigRaw(configPath?: string): Promise<string> {
54
+ const path = configPath ?? "wrangler.jsonc";
55
+ const file = Bun.file(path);
56
+ if (!(await file.exists())) {
57
+ throw new CLIError(
58
+ `Config file not found: ${path}`,
59
+ ExitCode.INVALID_USAGE
60
+ );
61
+ }
62
+ return await file.text();
63
+ }
64
+
65
+ /**
66
+ * Write content back to wrangler.jsonc.
67
+ */
68
+ async function writeConfigRaw(
69
+ content: string,
70
+ configPath?: string
71
+ ): Promise<void> {
72
+ const path = configPath ?? "wrangler.jsonc";
73
+ await Bun.write(path, content);
74
+ }
75
+
76
+ /**
77
+ * Get FormatOptions from the global commander opts (--json / --quiet).
78
+ */
79
+ /**
80
+ * Get FormatOptions from the command opts using optsWithGlobals() so that
81
+ * globally defined flags (--json, --quiet) are resolved correctly even when
82
+ * called from deeply nested subcommands.
83
+ */
84
+ function formatOpts(program: Command): FormatOptions {
85
+ const opts = program.optsWithGlobals<{ json?: boolean; quiet?: boolean }>();
86
+ return { json: Boolean(opts.json), quiet: Boolean(opts.quiet) };
87
+ }
88
+
89
+ /**
90
+ * Prompt the user for a secret value via stdin (password-style masked input).
91
+ *
92
+ * Uses terminal raw mode to suppress echo. Falls back to plain text read
93
+ * when raw mode is unavailable (e.g. non-TTY environments).
94
+ */
95
+ async function promptSecret(promptText: string): Promise<string> {
96
+ process.stdout.write(`${theme.info(icons.info)} ${promptText}: `);
97
+
98
+ // Attempt to use raw mode for password masking
99
+ if (process.stdin.isTTY) {
100
+ let prevRaw = false;
101
+ try {
102
+ prevRaw =
103
+ (
104
+ process.stdin as unknown as { isRawMode?: () => boolean }
105
+ ).isRawMode?.() ?? false;
106
+ } catch {
107
+ // isRawMode not available
108
+ }
109
+ try {
110
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
111
+ } catch {
112
+ // Raw mode not supported — fall through to plain read
113
+ }
114
+
115
+ let input = "";
116
+ try {
117
+ for await (const chunk of Bun.stdin.stream() as unknown as AsyncIterable<Uint8Array>) {
118
+ const text = new TextDecoder().decode(chunk);
119
+ for (const char of text) {
120
+ if (char === "\n" || char === "\r") {
121
+ process.stdout.write("\n");
122
+ return input;
123
+ }
124
+ if (char === "\x03") {
125
+ // Ctrl+C
126
+ process.stdout.write("\n");
127
+ throw new CLIError("Operation cancelled", ExitCode.INVALID_USAGE);
128
+ }
129
+ if (char === "\x7f") {
130
+ // Backspace
131
+ if (input.length > 0) {
132
+ input = input.slice(0, -1);
133
+ process.stdout.write("\b \b");
134
+ }
135
+ } else if (char >= "\x20") {
136
+ // Printable chars only (skip control characters)
137
+ input += char;
138
+ process.stdout.write("*");
139
+ }
140
+ }
141
+ }
142
+ } finally {
143
+ try {
144
+ if (process.stdin.setRawMode) process.stdin.setRawMode(prevRaw);
145
+ } catch {
146
+ // ignore
147
+ }
148
+ }
149
+ return input;
150
+ }
151
+
152
+ // Non-TTY fallback: read single line from stdin
153
+ return await readLine();
154
+ }
155
+
156
+ /** Read a single line from stdin (non-TTY fallback). */
157
+ async function readLine(): Promise<string> {
158
+ let line = "";
159
+ for await (const chunk of Bun.stdin.stream() as unknown as AsyncIterable<Uint8Array>) {
160
+ const text = new TextDecoder().decode(chunk);
161
+ const newlineIdx = text.indexOf("\n");
162
+ if (newlineIdx >= 0) {
163
+ line += text.substring(0, newlineIdx);
164
+ break;
165
+ }
166
+ line += text;
167
+ }
168
+ return line.trim();
169
+ }
170
+
171
+ /**
172
+ * Generate a cryptographically random hex key of the specified byte length.
173
+ */
174
+ function generateKey(bytes = 32): string {
175
+ const buf = new Uint8Array(bytes);
176
+ crypto.getRandomValues(buf);
177
+ return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Command registration
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export function registerConfigCommand(program: Command): void {
185
+ const configCmd = program
186
+ .command("config")
187
+ .summary("Manage configuration, secrets, and keys")
188
+ .description(
189
+ `Manage your Hoox configuration and secrets.
190
+
191
+ CONFIGURATION:
192
+ The main configuration lives in wrangler.jsonc at the project root.
193
+ Use 'config show' to view current settings and 'config set' to modify them.
194
+
195
+ SECRETS:
196
+ Secrets are stored in Cloudflare and managed via 'config secrets'.
197
+ Local development uses .dev.vars files in each worker directory.
198
+
199
+ KEYS:
200
+ Generate and manage internal auth keys for inter-worker communication.
201
+ Keys are stored in the .keys/ directory (add to .gitignore).
202
+
203
+ EXAMPLES:
204
+ hoox config show Display current configuration
205
+ hoox config set workers.agent-worker.enabled false
206
+ hoox config secrets list List secrets for a worker
207
+ hoox config secrets set trade-worker BINANCE_API_KEY
208
+ hoox config keys generate Generate new internal keys`
209
+ );
210
+
211
+ // ──────────────────────────────────────────────────────────────────────
212
+ // show
213
+ // ──────────────────────────────────────────────────────────────────────
214
+ configCmd
215
+ .command("show")
216
+ .summary("Display current wrangler.jsonc configuration")
217
+ .description(
218
+ `Display the current wrangler.jsonc configuration in a formatted table.
219
+
220
+ Output includes:
221
+ - Global settings (account_id, subdomain_prefix, etc.)
222
+ - All workers with their enabled status, path, secrets count, and vars
223
+
224
+ OPTIONS:
225
+ --json Output raw JSON instead of formatted table
226
+
227
+ EXAMPLES:
228
+ hoox config show
229
+ hoox config show --json`
230
+ )
231
+ .action(async () => {
232
+ const opts = formatOpts(program);
233
+
234
+ try {
235
+ if (opts.json) {
236
+ // Raw JSON output — just dump the file content parsed as JSON
237
+ const raw = await readConfigRaw();
238
+ const parsed = JSON.parse(raw);
239
+ formatJson(parsed, opts);
240
+ return;
241
+ }
242
+
243
+ const svc = new ConfigService();
244
+ await svc.load();
245
+
246
+ // Global section
247
+ const global = svc.getGlobal();
248
+ const globalPairs: Record<string, string> = {};
249
+ for (const [k, v] of Object.entries(global)) {
250
+ globalPairs[k] = v ?? "(not set)";
251
+ }
252
+
253
+ process.stdout.write(`${theme.heading("\nGlobal Configuration")}\n`);
254
+ formatKeyValue(globalPairs, opts);
255
+
256
+ // Workers table
257
+ const workers = svc.listWorkers();
258
+ if (workers.length > 0) {
259
+ process.stdout.write(`\n${theme.heading("Workers")}\n`);
260
+ const rows = workers.map((name) => {
261
+ const w = svc.getWorker(name)!;
262
+ return {
263
+ Worker: name,
264
+ Enabled: w.enabled ? `${theme.success("•")} yes` : "-",
265
+ Path: w.path,
266
+ Secrets: w.secrets?.length ? String(w.secrets.length) : "-",
267
+ Vars:
268
+ w.vars && Object.keys(w.vars).length
269
+ ? String(Object.keys(w.vars).length)
270
+ : "-",
271
+ };
272
+ });
273
+ formatTable(rows, opts);
274
+ }
275
+ } catch (err: unknown) {
276
+ const message = err instanceof Error ? err.message : String(err);
277
+ formatError(
278
+ new CLIError(`Failed to show config: ${message}`, ExitCode.ERROR),
279
+ opts
280
+ );
281
+ }
282
+ });
283
+
284
+ // ──────────────────────────────────────────────────────────────────────
285
+ // set <key> <value>
286
+ // ──────────────────────────────────────────────────────────────────────
287
+ configCmd
288
+ .command("set <key> <value>")
289
+ .summary("Update a config value in wrangler.jsonc")
290
+ .description(
291
+ `Update a configuration value in wrangler.jsonc.
292
+
293
+ ARGUMENTS:
294
+ key Dot-notation path to the config value (e.g., workers.agent-worker.enabled)
295
+ value New value (auto-detects type: boolean, number, or string)
296
+
297
+ PATH EXAMPLES:
298
+ global.subdomain_prefix → Set subdomain
299
+ workers.trade-worker.enabled → Enable/disable worker
300
+ workers.agent-worker.vars.interval → Set worker variable
301
+
302
+ EXAMPLES:
303
+ hoox config set global.subdomain_prefix myapp
304
+ hoox config set workers.trade-worker.enabled false
305
+ hoox config set workers.agent-worker.vars.interval 5`
306
+ )
307
+ .action(async (key: string, value: string) => {
308
+ const opts = formatOpts(program);
309
+
310
+ try {
311
+ const raw = await readConfigRaw();
312
+ const jsonPath = keyToPath(key);
313
+
314
+ // Attempt the edit — jsonc-parser throws on invalid paths
315
+ let edits;
316
+ try {
317
+ edits = modify(raw, jsonPath, parseValue(value), {
318
+ formattingOptions: FORMATTING_OPTIONS,
319
+ });
320
+ } catch (err: unknown) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ throw new CLIError(
323
+ `Invalid key path "${key}": ${msg}`,
324
+ ExitCode.INVALID_USAGE
325
+ );
326
+ }
327
+
328
+ const updated = applyEdits(raw, edits);
329
+ await writeConfigRaw(updated);
330
+
331
+ formatSuccess(`Updated "${key}" = "${value}" in wrangler.jsonc`, opts);
332
+ } catch (err: unknown) {
333
+ if (err instanceof CLIError) {
334
+ formatError(err, opts);
335
+ } else {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ formatError(
338
+ new CLIError(`Failed to set config: ${message}`, ExitCode.ERROR),
339
+ opts
340
+ );
341
+ }
342
+ }
343
+ });
344
+
345
+ // ──────────────────────────────────────────────────────────────────────
346
+ // secrets subcommand group
347
+ // ──────────────────────────────────────────────────────────────────────
348
+ const secretsCmd = configCmd
349
+ .command("secrets")
350
+ .summary("Manage Cloudflare Worker secrets")
351
+ .description(
352
+ `Manage secrets for your Cloudflare Workers.
353
+
354
+ Secrets are defined in wrangler.jsonc under each worker's 'secrets' array.
355
+ They are stored securely in Cloudflare and uploaded via 'wrangler secret put'.
356
+
357
+ LOCAL DEVELOPMENT:
358
+ For local development, create .dev.vars files in each worker directory.
359
+ The 'config secrets sync' command can generate these from your config.
360
+
361
+ EXAMPLES:
362
+ hoox config secrets list List all secrets
363
+ hoox config secrets list trade-worker List secrets for one worker
364
+ hoox config secrets set trade-worker API_KEY Set a secret value
365
+ hoox config secrets delete trade-worker API_KEY Delete a secret
366
+ hoox config secrets sync Sync secrets to .dev.vars`
367
+ );
368
+
369
+ // secrets list [worker]
370
+ secretsCmd
371
+ .command("list [worker]")
372
+ .summary("List secrets for all workers or a specific worker")
373
+ .description(
374
+ `List the secrets declared in wrangler.jsonc for workers.
375
+
376
+ ARGUMENTS:
377
+ worker Optional worker name to filter by
378
+
379
+ EXAMPLES:
380
+ hoox config secrets list
381
+ hoox config secrets list trade-worker`
382
+ )
383
+ .action(async (worker?: string) => {
384
+ const opts = formatOpts(program);
385
+
386
+ try {
387
+ const svc = await SecretsService.create();
388
+
389
+ if (worker) {
390
+ const secrets = svc.listSecrets(worker);
391
+ if (secrets.length === 0) {
392
+ process.stdout.write(
393
+ `${theme.dim(`No secrets declared for worker "${worker}".`)}\n`
394
+ );
395
+ return;
396
+ }
397
+
398
+ if (opts.json) {
399
+ formatJson({ worker, secrets }, opts);
400
+ } else {
401
+ process.stdout.write(
402
+ `${theme.heading(`\nSecrets for ${worker}`)}\n`
403
+ );
404
+ for (const s of secrets) {
405
+ process.stdout.write(` ${theme.label("•")} ${s}\n`);
406
+ }
407
+ }
408
+ } else {
409
+ const all = svc.listAllSecrets();
410
+ const workers = Object.keys(all);
411
+
412
+ if (workers.length === 0) {
413
+ process.stdout.write(
414
+ `${theme.dim("No secrets declared for any worker.")}\n`
415
+ );
416
+ return;
417
+ }
418
+
419
+ if (opts.json) {
420
+ formatJson(all, opts);
421
+ } else {
422
+ process.stdout.write(`${theme.heading("\nSecrets by Worker")}\n`);
423
+ for (const [name, secrets] of Object.entries(all)) {
424
+ process.stdout.write(
425
+ `\n ${theme.bold(name)} (${secrets.length})\n`
426
+ );
427
+ for (const s of secrets) {
428
+ process.stdout.write(` ${theme.dim("•")} ${s}\n`);
429
+ }
430
+ }
431
+ }
432
+ }
433
+ } catch (err: unknown) {
434
+ const message = err instanceof Error ? err.message : String(err);
435
+ formatError(
436
+ new CLIError(`Failed to list secrets: ${message}`, ExitCode.ERROR),
437
+ opts
438
+ );
439
+ }
440
+ });
441
+
442
+ // secrets set <worker> <name>
443
+ secretsCmd
444
+ .command("set <worker> <name>")
445
+ .summary("Set a secret value for a worker")
446
+ .description(
447
+ `Set a secret value for a worker and sync to Cloudflare.
448
+
449
+ ARGUMENTS:
450
+ worker Worker name (e.g., trade-worker, agent-worker)
451
+ name Secret name (must be declared in wrangler.jsonc)
452
+
453
+ The command will prompt for the secret value (hidden input).
454
+ It writes to the worker's .dev.vars file and syncs to Cloudflare.
455
+
456
+ EXAMPLES:
457
+ hoox config secrets set trade-worker BINANCE_API_KEY
458
+ hoox config secrets set agent-worker OPENAI_KEY`
459
+ )
460
+ .action(async (workerName: string, secretName: string) => {
461
+ const opts = formatOpts(program);
462
+
463
+ try {
464
+ const svc = await SecretsService.create();
465
+ const declared = svc.listSecrets(workerName);
466
+
467
+ if (!declared.includes(secretName) && declared.length > 0) {
468
+ throw new CLIError(
469
+ `Secret "${secretName}" is not declared for worker "${workerName}". ` +
470
+ `Declared secrets: ${declared.join(", ")}`,
471
+ ExitCode.INVALID_USAGE
472
+ );
473
+ }
474
+
475
+ const value = await promptSecret(`Enter value for "${secretName}"`);
476
+ if (!value) {
477
+ throw new CLIError(
478
+ "Secret value cannot be empty",
479
+ ExitCode.INVALID_USAGE
480
+ );
481
+ }
482
+
483
+ // Write value to .dev.vars (workers/<name> mirrors the project layout)
484
+ const devVarsPath = `workers/${workerName}/.dev.vars`;
485
+ await updateDevVars(devVarsPath, secretName, value);
486
+
487
+ formatSuccess(`Secret "${secretName}" updated in ${devVarsPath}`, opts);
488
+
489
+ // Sync to Cloudflare with spinner
490
+ const syncSpin = spinner();
491
+ syncSpin.start("Syncing to Cloudflare...");
492
+ const result = await svc.syncToCloudflare(workerName);
493
+ if (result.success) {
494
+ syncSpin.stop(`Secret "${secretName}" synced to Cloudflare`);
495
+ } else {
496
+ syncSpin.stop(
497
+ `Sync partial: ${result.error ?? "unknown error"}`
498
+ );
499
+ formatError(
500
+ new CLIError(
501
+ `Sync partial: ${result.error ?? "unknown error"}`,
502
+ ExitCode.ERROR
503
+ ),
504
+ opts
505
+ );
506
+ }
507
+ } catch (err: unknown) {
508
+ if (err instanceof CLIError) {
509
+ formatError(err, opts);
510
+ } else {
511
+ const message = err instanceof Error ? err.message : String(err);
512
+ formatError(
513
+ new CLIError(`Failed to set secret: ${message}`, ExitCode.ERROR),
514
+ opts
515
+ );
516
+ }
517
+ }
518
+ });
519
+
520
+ // secrets delete <worker> <name>
521
+ secretsCmd
522
+ .command("delete <worker> <name>")
523
+ .summary("Delete a secret from Cloudflare")
524
+ .description(
525
+ `Delete a secret from Cloudflare Workers.
526
+
527
+ ARGUMENTS:
528
+ worker Worker name (e.g., trade-worker, agent-worker)
529
+ name Secret name to delete
530
+
531
+ This removes the secret from Cloudflare and from the worker's .dev.vars file.
532
+
533
+ EXAMPLES:
534
+ hoox config secrets delete trade-worker BINANCE_API_KEY`
535
+ )
536
+ .action(async (workerName: string, secretName: string) => {
537
+ const opts = formatOpts(program);
538
+
539
+ try {
540
+ const svc = await SecretsService.create();
541
+ const declared = svc.listSecrets(workerName);
542
+
543
+ if (!declared.includes(secretName)) {
544
+ throw new CLIError(
545
+ `Secret "${secretName}" is not declared for worker "${workerName}".`,
546
+ ExitCode.INVALID_USAGE
547
+ );
548
+ }
549
+
550
+ // Wrangler secret delete
551
+ const proc = Bun.spawn(["wrangler", "secret", "delete", secretName], {
552
+ cwd: `workers/${workerName}`,
553
+ stdout: "pipe",
554
+ stderr: "pipe",
555
+ });
556
+
557
+ const exitCode = await proc.exited;
558
+ if (exitCode !== 0) {
559
+ const stderrText = await new Response(proc.stderr).text();
560
+ throw new CLIError(
561
+ `wrangler exited with code ${exitCode}: ${stderrText.trim()}`,
562
+ ExitCode.ERROR
563
+ );
564
+ }
565
+
566
+ // Also remove from .dev.vars if present
567
+ const devVarsPath = `workers/${workerName}/.dev.vars`;
568
+ const devFile = Bun.file(devVarsPath);
569
+ if (await devFile.exists()) {
570
+ let content = await devFile.text();
571
+ const lines = content.split("\n");
572
+ const filtered = lines.filter(
573
+ (line) => !line.startsWith(`${secretName}=`) && line.trim() !== ""
574
+ );
575
+ // Keep commented lines, remove empty ones
576
+ await Bun.write(
577
+ devVarsPath,
578
+ filtered.join("\n") + (filtered.length > 0 ? "\n" : "")
579
+ );
580
+ }
581
+
582
+ formatSuccess(`Secret "${secretName}" deleted from Cloudflare`, opts);
583
+ } catch (err: unknown) {
584
+ if (err instanceof CLIError) {
585
+ formatError(err, opts);
586
+ } else {
587
+ const message = err instanceof Error ? err.message : String(err);
588
+ formatError(
589
+ new CLIError(`Failed to delete secret: ${message}`, ExitCode.ERROR),
590
+ opts
591
+ );
592
+ }
593
+ }
594
+ });
595
+
596
+ // secrets sync [worker]
597
+ secretsCmd
598
+ .command("sync [worker]")
599
+ .summary("Sync secrets to Cloudflare")
600
+ .description(
601
+ `Sync secrets from .dev.vars files to Cloudflare Workers.
602
+
603
+ ARGUMENTS:
604
+ worker Optional worker name to sync (syncs all if not specified)
605
+
606
+ This reads .dev.vars files and uploads secrets to Cloudflare via wrangler.
607
+
608
+ EXAMPLES:
609
+ hoox config secrets sync Sync all workers
610
+ hoox config secrets sync trade-worker Sync specific worker`
611
+ )
612
+ .action(async (workerName?: string) => {
613
+ const opts = formatOpts(program);
614
+
615
+ try {
616
+ const svc = await SecretsService.create();
617
+
618
+ if (workerName) {
619
+ const syncSpin = spinner();
620
+ syncSpin.start(`Syncing secrets for "${workerName}"...`);
621
+ const result = await svc.syncToCloudflare(workerName);
622
+ if (result.success) {
623
+ syncSpin.stop(
624
+ `Synced ${result.data?.length ?? 0} secrets for "${workerName}"`
625
+ );
626
+ } else {
627
+ syncSpin.stop(
628
+ `Sync failed: ${result.error ?? "unknown error"}`
629
+ );
630
+ formatError(
631
+ new CLIError(
632
+ `Sync failed: ${result.error ?? "unknown error"}`,
633
+ ExitCode.ERROR
634
+ ),
635
+ opts
636
+ );
637
+ }
638
+ } else {
639
+ const all = svc.listAllSecrets();
640
+ const workers = Object.keys(all);
641
+
642
+ if (workers.length === 0) {
643
+ formatSuccess("No secrets to sync.", opts);
644
+ return;
645
+ }
646
+
647
+ let synced = 0;
648
+ let failed = 0;
649
+ const syncSpin = spinner();
650
+
651
+ for (const name of workers) {
652
+ syncSpin.start(`Syncing ${name}...`);
653
+ const result = await svc.syncToCloudflare(name);
654
+ if (result.success) {
655
+ syncSpin.stop(
656
+ `${theme.success("synced")} ${result.data?.length ?? 0} for ${name}`
657
+ );
658
+ synced++;
659
+ } else {
660
+ syncSpin.stop(`${theme.error("failed")} ${name}`);
661
+ failed++;
662
+ }
663
+ }
664
+
665
+ if (failed === 0) {
666
+ formatSuccess(`All ${synced} workers synced successfully`, opts);
667
+ } else {
668
+ formatError(
669
+ new CLIError(
670
+ `${synced} synced, ${failed} failed`,
671
+ ExitCode.ERROR
672
+ ),
673
+ opts
674
+ );
675
+ }
676
+ }
677
+ } catch (err: unknown) {
678
+ if (err instanceof CLIError) {
679
+ formatError(err, opts);
680
+ } else {
681
+ const message = err instanceof Error ? err.message : String(err);
682
+ formatError(
683
+ new CLIError(`Failed to sync secrets: ${message}`, ExitCode.ERROR),
684
+ opts
685
+ );
686
+ }
687
+ }
688
+ });
689
+
690
+ // ──────────────────────────────────────────────────────────────────────
691
+ // env subcommand group
692
+ // ──────────────────────────────────────────────────────────────────────
693
+ registerEnvCommand(configCmd);
694
+
695
+ // ──────────────────────────────────────────────────────────────────────
696
+ // kv subcommand group
697
+ // ──────────────────────────────────────────────────────────────────────
698
+ registerKvCommand(configCmd);
699
+
700
+ // ──────────────────────────────────────────────────────────────────────
701
+ // keys subcommand group
702
+ // ──────────────────────────────────────────────────────────────────────
703
+ const keysCmd = configCmd
704
+ .command("keys")
705
+ .summary("Manage internal auth keys")
706
+ .description(
707
+ `Generate and manage internal auth keys for inter-worker communication.
708
+
709
+ Keys are stored in the .keys/ directory as .env files (add .keys/ to .gitignore).
710
+ These keys are used for authentication between workers.
711
+
712
+ EXAMPLES:
713
+ hoox config keys generate Generate new keys
714
+ hoox config keys list List existing keys`
715
+ );
716
+
717
+ // keys generate
718
+ keysCmd
719
+ .command("generate")
720
+ .summary("Generate new internal auth keys")
721
+ .description(
722
+ `Generate new internal auth keys and save to .keys/ directory.
723
+
724
+ Creates the following keys:
725
+ - INTERNAL_SERVICE_KEY (32 char)
726
+ - WEBHOOK_API_KEY (32 char)
727
+ - AGENT_INTERNAL_KEY (32 char)
728
+ - TELEGRAM_BOT_TOKEN (16 char)
729
+ - INTERNAL_KEY (32 char)
730
+
731
+ WARNING: Add .keys/ to your .gitignore to avoid committing secrets!
732
+
733
+ EXAMPLES:
734
+ hoox config keys generate`
735
+ )
736
+ .action(async () => {
737
+ const opts = formatOpts(program);
738
+
739
+ try {
740
+ const keysDir = ".keys";
741
+ if (!existsSync(keysDir)) {
742
+ mkdirSync(keysDir, { recursive: true });
743
+ }
744
+
745
+ const keys: Record<string, string> = {
746
+ INTERNAL_SERVICE_KEY: generateKey(),
747
+ WEBHOOK_API_KEY: generateKey(),
748
+ AGENT_INTERNAL_KEY: generateKey(),
749
+ TELEGRAM_BOT_TOKEN: generateKey(16),
750
+ INTERNAL_KEY: generateKey(),
751
+ };
752
+
753
+ for (const [name, value] of Object.entries(keys)) {
754
+ const filePath = `${keysDir}/${name.toLowerCase()}.env`;
755
+ await Bun.write(filePath, `${name}=${value}\n`);
756
+ }
757
+
758
+ formatSuccess(
759
+ `Generated ${Object.keys(keys).length} keys in ${keysDir}/`,
760
+ opts
761
+ );
762
+
763
+ if (!opts.quiet && !opts.json) {
764
+ process.stdout.write(
765
+ `${theme.warning("!")} Keep these keys secret. Add ${keysDir}/ to .gitignore.\n`
766
+ );
767
+ formatKeyValue(keys, opts);
768
+ }
769
+ } catch (err: unknown) {
770
+ const message = err instanceof Error ? err.message : String(err);
771
+ formatError(
772
+ new CLIError(`Failed to generate keys: ${message}`, ExitCode.ERROR),
773
+ opts
774
+ );
775
+ }
776
+ });
777
+
778
+ // keys list
779
+ keysCmd
780
+ .command("list")
781
+ .summary("List existing internal auth keys")
782
+ .description(
783
+ `List existing internal auth keys from the .keys/ directory.
784
+
785
+ Shows key names (values are hidden for security).
786
+
787
+ EXAMPLES:
788
+ hoox config keys list`
789
+ )
790
+ .action(async () => {
791
+ const opts = formatOpts(program);
792
+
793
+ try {
794
+ const keysDir = ".keys";
795
+ if (!existsSync(keysDir)) {
796
+ process.stdout.write(`${theme.dim("No .keys/ directory found.")}\n`);
797
+ return;
798
+ }
799
+
800
+ // Use Bun.Glob to list .keys/*.env files
801
+ const glob = new Bun.Glob("*.env");
802
+ const entries: string[] = [];
803
+ for await (const f of glob.scan({ cwd: keysDir, absolute: false })) {
804
+ entries.push(f);
805
+ }
806
+
807
+ if (entries.length === 0) {
808
+ process.stdout.write(
809
+ `${theme.dim("No key files found in .keys/")}\n`
810
+ );
811
+ return;
812
+ }
813
+
814
+ const keyMap: Record<string, string> = {};
815
+ for (const entry of entries) {
816
+ const filePath = `${keysDir}/${entry}`;
817
+ const content = await (await Bun.file(filePath).text()).trim();
818
+ const eqIdx = content.indexOf("=");
819
+ if (eqIdx > 0) {
820
+ keyMap[content.substring(0, eqIdx)] = "****";
821
+ }
822
+ }
823
+
824
+ if (opts.json) {
825
+ formatJson({ keys: entries.length, files: entries }, opts);
826
+ } else {
827
+ process.stdout.write(
828
+ `${theme.heading(`\nKey files in ${keysDir}/`)}\n`
829
+ );
830
+ formatKeyValue(keyMap, opts);
831
+ }
832
+ } catch (err: unknown) {
833
+ const message = err instanceof Error ? err.message : String(err);
834
+ formatError(
835
+ new CLIError(`Failed to list keys: ${message}`, ExitCode.ERROR),
836
+ opts
837
+ );
838
+ }
839
+ });
840
+ }
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // Internal helpers (not exported)
844
+ // ---------------------------------------------------------------------------
845
+
846
+ /**
847
+ * Parse a string value and attempt to coerce it to a proper type for JSON.
848
+ * Numbers and booleans are detected; everything else stays as a string.
849
+ */
850
+ function parseValue(raw: string): string | number | boolean {
851
+ // Boolean
852
+ if (raw === "true") return true;
853
+ if (raw === "false") return false;
854
+
855
+ // Integer / float
856
+ if (/^-?\d+(\.\d+)?$/.test(raw)) {
857
+ return Number(raw);
858
+ }
859
+
860
+ // Keep as string
861
+ return raw;
862
+ }
863
+
864
+ /**
865
+ * Update or add a key=value entry in a .dev.vars file.
866
+ */
867
+ async function updateDevVars(
868
+ filePath: string,
869
+ key: string,
870
+ value: string
871
+ ): Promise<void> {
872
+ const file = Bun.file(filePath);
873
+ if (await file.exists()) {
874
+ const content = await file.text();
875
+ const lines = content.split("\n");
876
+ const updated: string[] = [];
877
+ let found = false;
878
+
879
+ for (const line of lines) {
880
+ if (line.startsWith(`${key}=`)) {
881
+ updated.push(`${key}=${value}`);
882
+ found = true;
883
+ } else {
884
+ updated.push(line);
885
+ }
886
+ }
887
+
888
+ if (!found) {
889
+ updated.push(`${key}=${value}`);
890
+ }
891
+
892
+ // Remove trailing empty lines
893
+ while (updated.length > 0 && updated[updated.length - 1] === "") {
894
+ updated.pop();
895
+ }
896
+
897
+ await Bun.write(filePath, updated.join("\n") + "\n");
898
+ } else {
899
+ await Bun.write(filePath, `${key}=${value}\n`);
900
+ }
901
+ }