@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,583 @@
1
+ /**
2
+ * Unit tests for `config-command.ts` — the `hoox config` command group.
3
+ *
4
+ * Tests verify:
5
+ * - Subcommand registration (show, set, secrets, keys)
6
+ * - Output formatting (human-readable vs --json)
7
+ * - Error handling for missing config files, invalid paths, etc.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
10
+ import { Command } from "commander";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ import { registerConfigCommand } from "./config-command.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Minimal valid wrangler.jsonc content for testing. */
22
+ const VALID_CONFIG = `{
23
+ "global": {
24
+ "cloudflare_account_id": "abc123",
25
+ "cloudflare_api_token": "token-placeholder"
26
+ },
27
+ "workers": {
28
+ "d1-worker": {
29
+ "enabled": true,
30
+ "path": "workers/d1-worker",
31
+ "vars": {
32
+ "database_name": "my-database"
33
+ },
34
+ "secrets": ["SECRET_ONE"]
35
+ },
36
+ "trade-worker": {
37
+ "enabled": true,
38
+ "path": "workers/trade-worker",
39
+ "vars": {},
40
+ "secrets": ["API_KEY", "API_SECRET"]
41
+ }
42
+ }
43
+ }`;
44
+
45
+ /** Create a real wrangler.jsonc in a temp dir and cd into it. */
46
+ function setupTempConfig(name = "hoox-cli-test"): string {
47
+ const dir = mkdtempSync(join(tmpdir(), `${name}-`));
48
+ writeFileSync(join(dir, "wrangler.jsonc"), VALID_CONFIG);
49
+ return dir;
50
+ }
51
+
52
+ /** Capture all writes to process.stdout for assertion. */
53
+ class OutputCapture {
54
+ private lines: string[] = [];
55
+ private original: typeof process.stdout.write;
56
+
57
+ constructor() {
58
+ this.original = process.stdout.write.bind(process.stdout);
59
+ process.stdout.write = ((chunk: string | Uint8Array, cb?: () => void) => {
60
+ const str =
61
+ typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
62
+ this.lines.push(str);
63
+ if (cb) cb();
64
+ return true;
65
+ }) as typeof process.stdout.write;
66
+ }
67
+
68
+ text(): string {
69
+ return this.lines.join("");
70
+ }
71
+
72
+ json<T = unknown>(): T {
73
+ const text = this.text();
74
+ // Find the first JSON object/array in the output
75
+ for (const line of text.split("\n")) {
76
+ const trimmed = line.trim();
77
+ if (
78
+ (trimmed.startsWith("{") || trimmed.startsWith("[")) &&
79
+ (trimmed.endsWith("}") || trimmed.endsWith("]"))
80
+ ) {
81
+ try {
82
+ return JSON.parse(trimmed) as T;
83
+ } catch {
84
+ // try next line
85
+ }
86
+ }
87
+ }
88
+ // Fallback: try parsing full output
89
+ return JSON.parse(text) as T;
90
+ }
91
+
92
+ restore(): void {
93
+ process.stdout.write = this.original;
94
+ }
95
+ }
96
+
97
+ /** Create a fresh Commander program with global options for testing. */
98
+ function makeProgram(): Command {
99
+ const program = new Command();
100
+ program.exitOverride();
101
+ program.option("--json", "JSON output");
102
+ program.option("--quiet", "Quiet mode");
103
+ return program;
104
+ }
105
+
106
+ /**
107
+ * Parse args and wait for all async actions to complete.
108
+ * Uses Commander's internal handling: when exitOverride is set,
109
+ * async action rejections surface as errors.
110
+ */
111
+ async function runCommand(
112
+ program: Command,
113
+ args: string[]
114
+ ): Promise<OutputCapture> {
115
+ const capture = new OutputCapture();
116
+ try {
117
+ // Commander v13 parseAsync waits for async actions
118
+ await (
119
+ program as Command & {
120
+ parseAsync?: (a: string[], o: { from: string }) => Promise<void>;
121
+ }
122
+ ).parseAsync?.([...args], { from: "user" });
123
+
124
+ // Fallback: if parseAsync isn't available, parse synchronously and wait
125
+ if (
126
+ typeof (program as Command & { parseAsync?: unknown }).parseAsync !==
127
+ "function"
128
+ ) {
129
+ program.parse([...args], { from: "user" });
130
+ // Give async actions a tick to complete
131
+ await new Promise((r) => setTimeout(r, 50));
132
+ }
133
+ } catch (_err) {
134
+ // Commander throws on help, version, etc. — capture handles it
135
+ }
136
+ return capture;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Tests: Command registration
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe("registerConfigCommand", () => {
144
+ it("registers the `config` command on the program", () => {
145
+ const program = makeProgram();
146
+ registerConfigCommand(program);
147
+
148
+ const cmd = program.commands.find((c) => c.name() === "config");
149
+ expect(cmd).toBeDefined();
150
+ expect(cmd?.summary()).toContain("Manage configuration");
151
+ });
152
+
153
+ it("registers the `show` subcommand", () => {
154
+ const program = makeProgram();
155
+ registerConfigCommand(program);
156
+
157
+ const configCmd = program.commands.find((c) => c.name() === "config");
158
+ const showCmd = configCmd?.commands.find((c) => c.name() === "show");
159
+ expect(showCmd).toBeDefined();
160
+ expect(showCmd?.description()).toContain("Display");
161
+ });
162
+
163
+ it("registers the `set` subcommand with key and value arguments", () => {
164
+ const program = makeProgram();
165
+ registerConfigCommand(program);
166
+
167
+ const configCmd = program.commands.find((c) => c.name() === "config");
168
+ const setCmd = configCmd?.commands.find((c) => c.name() === "set");
169
+ expect(setCmd).toBeDefined();
170
+ expect(setCmd?.description()).toContain("Update");
171
+ });
172
+
173
+ it("registers the `secrets` subcommand group with all sub-subcommands", () => {
174
+ const program = makeProgram();
175
+ registerConfigCommand(program);
176
+
177
+ const configCmd = program.commands.find((c) => c.name() === "config");
178
+ const secretsCmd = configCmd?.commands.find((c) => c.name() === "secrets");
179
+ expect(secretsCmd).toBeDefined();
180
+ expect(secretsCmd?.description()).toContain("Cloudflare");
181
+
182
+ const subNames = secretsCmd?.commands.map((c) => c.name()) ?? [];
183
+ expect(subNames).toContain("list");
184
+ expect(subNames).toContain("set");
185
+ expect(subNames).toContain("delete");
186
+ expect(subNames).toContain("sync");
187
+ });
188
+
189
+ it("registers the `keys` subcommand group with generate and list", () => {
190
+ const program = makeProgram();
191
+ registerConfigCommand(program);
192
+
193
+ const configCmd = program.commands.find((c) => c.name() === "config");
194
+ const keysCmd = configCmd?.commands.find((c) => c.name() === "keys");
195
+ expect(keysCmd).toBeDefined();
196
+ expect(keysCmd?.description()).toContain("auth");
197
+
198
+ const subNames = keysCmd?.commands.map((c) => c.name()) ?? [];
199
+ expect(subNames).toContain("generate");
200
+ expect(subNames).toContain("list");
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Tests: config show
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("config show", () => {
209
+ let tmpDir: string;
210
+ let originalCwd: string;
211
+
212
+ beforeEach(() => {
213
+ tmpDir = setupTempConfig();
214
+ originalCwd = process.cwd();
215
+ process.chdir(tmpDir);
216
+ });
217
+
218
+ afterEach(() => {
219
+ process.chdir(originalCwd);
220
+ rmSync(tmpDir, { recursive: true, force: true });
221
+ });
222
+
223
+ it("displays global config as key-value pairs in human-readable mode", async () => {
224
+ const program = makeProgram();
225
+ registerConfigCommand(program);
226
+
227
+ const capture = await runCommand(program, ["config", "show"]);
228
+ const text = capture.text();
229
+ capture.restore();
230
+
231
+ expect(text).toContain("Global Configuration");
232
+ expect(text).toContain("cloudflare_account_id");
233
+ expect(text).toContain("abc123");
234
+ expect(text).toContain("Workers");
235
+ expect(text).toContain("d1-worker");
236
+ });
237
+
238
+ it("outputs JSON when --json flag is set", async () => {
239
+ const program = makeProgram();
240
+ registerConfigCommand(program);
241
+
242
+ const capture = await runCommand(program, ["--json", "config", "show"]);
243
+ const json = capture.json<{
244
+ global?: Record<string, string>;
245
+ workers?: Record<string, unknown>;
246
+ }>();
247
+ capture.restore();
248
+
249
+ expect(json.global).toBeDefined();
250
+ expect(json.global?.cloudflare_account_id).toBe("abc123");
251
+ expect(json.workers).toBeDefined();
252
+ });
253
+
254
+ it("handles missing config file gracefully", async () => {
255
+ // Create empty temp dir without wrangler.jsonc
256
+ const emptyDir = mkdtempSync(join(tmpdir(), "empty-config-"));
257
+ process.chdir(emptyDir);
258
+
259
+ const program = makeProgram();
260
+ registerConfigCommand(program);
261
+
262
+ const capture = await runCommand(program, ["config", "show"]);
263
+ const text = capture.text();
264
+ capture.restore();
265
+
266
+ process.chdir(tmpDir);
267
+ rmSync(emptyDir, { recursive: true, force: true });
268
+
269
+ expect(text).toContain("Failed to show config");
270
+ });
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Tests: config set
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe("config set", () => {
278
+ let tmpDir: string;
279
+ let originalCwd: string;
280
+
281
+ beforeEach(() => {
282
+ tmpDir = setupTempConfig();
283
+ originalCwd = process.cwd();
284
+ process.chdir(tmpDir);
285
+ });
286
+
287
+ afterEach(() => {
288
+ process.chdir(originalCwd);
289
+ rmSync(tmpDir, { recursive: true, force: true });
290
+ });
291
+
292
+ it("updates a global config value", async () => {
293
+ const program = makeProgram();
294
+ registerConfigCommand(program);
295
+
296
+ const capture = await runCommand(program, [
297
+ "config",
298
+ "set",
299
+ "global.cloudflare_account_id",
300
+ "new-id-456",
301
+ ]);
302
+ const text = capture.text();
303
+ capture.restore();
304
+
305
+ // Read the file back to verify the update
306
+ const updated = await Bun.file("wrangler.jsonc").text();
307
+ const parsed = JSON.parse(updated) as {
308
+ global: { cloudflare_account_id: string };
309
+ };
310
+
311
+ expect(text).toContain("Updated");
312
+ expect(parsed.global.cloudflare_account_id).toBe("new-id-456");
313
+ });
314
+
315
+ it("updates a nested worker var value", async () => {
316
+ const program = makeProgram();
317
+ registerConfigCommand(program);
318
+
319
+ const capture = await runCommand(program, [
320
+ "config",
321
+ "set",
322
+ "workers.d1-worker.vars.database_name",
323
+ "new-db",
324
+ ]);
325
+ capture.restore();
326
+
327
+ const updated = await Bun.file("wrangler.jsonc").text();
328
+ const parsed = JSON.parse(updated) as {
329
+ workers: Record<string, { vars: Record<string, string> }>;
330
+ };
331
+
332
+ expect(parsed.workers["d1-worker"].vars.database_name).toBe("new-db");
333
+ });
334
+
335
+ it("coerces boolean strings to booleans", async () => {
336
+ const program = makeProgram();
337
+ registerConfigCommand(program);
338
+
339
+ const capture = await runCommand(program, [
340
+ "config",
341
+ "set",
342
+ "workers.d1-worker.enabled",
343
+ "false",
344
+ ]);
345
+ capture.restore();
346
+
347
+ const updated = await Bun.file("wrangler.jsonc").text();
348
+ const parsed = JSON.parse(updated) as {
349
+ workers: Record<string, { enabled: boolean }>;
350
+ };
351
+
352
+ expect(parsed.workers["d1-worker"].enabled).toBe(false);
353
+ });
354
+
355
+ it("coerces numeric strings to numbers", async () => {
356
+ const program = makeProgram();
357
+ registerConfigCommand(program);
358
+
359
+ const capture = await runCommand(program, [
360
+ "config",
361
+ "set",
362
+ "workers.trade-worker.vars.max_retries",
363
+ "5",
364
+ ]);
365
+ capture.restore();
366
+
367
+ const updated = await Bun.file("wrangler.jsonc").text();
368
+ const parsed = JSON.parse(updated) as {
369
+ workers: Record<string, { vars: Record<string, number> }>;
370
+ };
371
+
372
+ expect(parsed.workers["trade-worker"].vars.max_retries).toBe(5);
373
+ });
374
+
375
+ it("reports error for invalid key paths", async () => {
376
+ const program = makeProgram();
377
+ registerConfigCommand(program);
378
+
379
+ const capture = await runCommand(program, [
380
+ "config",
381
+ "set",
382
+ "nonexistent.deep.path",
383
+ "value",
384
+ ]);
385
+ const text = capture.text();
386
+ capture.restore();
387
+
388
+ // The command should output an error message
389
+ expect(text.length).toBeGreaterThan(0);
390
+ });
391
+ });
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Tests: config secrets list (no Cloudflare API needed)
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe("config secrets", () => {
398
+ let tmpDir: string;
399
+ let originalCwd: string;
400
+
401
+ beforeEach(() => {
402
+ tmpDir = setupTempConfig();
403
+ originalCwd = process.cwd();
404
+ process.chdir(tmpDir);
405
+ });
406
+
407
+ afterEach(() => {
408
+ process.chdir(originalCwd);
409
+ rmSync(tmpDir, { recursive: true, force: true });
410
+ });
411
+
412
+ it("lists secrets for all workers", async () => {
413
+ const program = makeProgram();
414
+ registerConfigCommand(program);
415
+
416
+ const capture = await runCommand(program, ["config", "secrets", "list"]);
417
+ const text = capture.text();
418
+ capture.restore();
419
+
420
+ expect(text).toContain("trade-worker");
421
+ expect(text).toContain("API_KEY");
422
+ expect(text).toContain("API_SECRET");
423
+ expect(text).toContain("d1-worker");
424
+ expect(text).toContain("SECRET_ONE");
425
+ });
426
+
427
+ it("lists secrets for a specific worker", async () => {
428
+ const program = makeProgram();
429
+ registerConfigCommand(program);
430
+
431
+ const capture = await runCommand(program, [
432
+ "config",
433
+ "secrets",
434
+ "list",
435
+ "trade-worker",
436
+ ]);
437
+ const text = capture.text();
438
+ capture.restore();
439
+
440
+ expect(text).toContain("trade-worker");
441
+ expect(text).toContain("API_KEY");
442
+ expect(text).toContain("API_SECRET");
443
+ });
444
+
445
+ it("outputs JSON for secrets list when --json is set", async () => {
446
+ const program = makeProgram();
447
+ registerConfigCommand(program);
448
+
449
+ const capture = await runCommand(program, [
450
+ "--json",
451
+ "config",
452
+ "secrets",
453
+ "list",
454
+ ]);
455
+ const json = capture.json<Record<string, string[]>>();
456
+ capture.restore();
457
+
458
+ expect(json["trade-worker"]).toBeDefined();
459
+ expect(json["trade-worker"]).toContain("API_KEY");
460
+ });
461
+
462
+ it("shows message when no secrets declared on specific worker", async () => {
463
+ const program = makeProgram();
464
+ registerConfigCommand(program);
465
+
466
+ const capture = await runCommand(program, [
467
+ "config",
468
+ "secrets",
469
+ "list",
470
+ "hoox",
471
+ ]);
472
+ const text = capture.text();
473
+ capture.restore();
474
+
475
+ // "hoox" is in the config but might not have secrets or may be missing
476
+ expect(text.length).toBeGreaterThan(0);
477
+ });
478
+ });
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Tests: config keys
482
+ // ---------------------------------------------------------------------------
483
+
484
+ describe("config keys", () => {
485
+ let tmpDir: string;
486
+ let originalCwd: string;
487
+
488
+ beforeEach(() => {
489
+ tmpDir = setupTempConfig();
490
+ originalCwd = process.cwd();
491
+ process.chdir(tmpDir);
492
+ });
493
+
494
+ afterEach(() => {
495
+ process.chdir(originalCwd);
496
+ rmSync(tmpDir, { recursive: true, force: true });
497
+ });
498
+
499
+ it("generates key files in .keys/ directory", async () => {
500
+ const program = makeProgram();
501
+ registerConfigCommand(program);
502
+
503
+ const capture = await runCommand(program, ["config", "keys", "generate"]);
504
+ const text = capture.text();
505
+ capture.restore();
506
+
507
+ expect(text).toContain("Generated");
508
+ expect(text).toContain(".keys/");
509
+
510
+ // Verify files were created
511
+ const internalKeyFile = Bun.file(".keys/internal_service_key.env");
512
+ expect(await internalKeyFile.exists()).toBe(true);
513
+ const content = await internalKeyFile.text();
514
+ expect(content).toMatch(/^INTERNAL_SERVICE_KEY=[a-f0-9]{64}\n$/);
515
+ });
516
+
517
+ it("lists generated key files", async () => {
518
+ // First generate keys
519
+ const prog1 = makeProgram();
520
+ registerConfigCommand(prog1);
521
+ const c1 = await runCommand(prog1, ["config", "keys", "generate"]);
522
+ c1.restore();
523
+
524
+ // Now list them
525
+ const prog2 = makeProgram();
526
+ registerConfigCommand(prog2);
527
+ const capture = await runCommand(prog2, ["config", "keys", "list"]);
528
+ const text = capture.text();
529
+ capture.restore();
530
+
531
+ expect(text).toContain("Key files");
532
+ expect(text).toContain("****"); // Values are masked
533
+ });
534
+
535
+ it("handles missing .keys/ directory gracefully for list", async () => {
536
+ const program = makeProgram();
537
+ registerConfigCommand(program);
538
+
539
+ const capture = await runCommand(program, ["config", "keys", "list"]);
540
+ const text = capture.text();
541
+ capture.restore();
542
+
543
+ expect(text).toContain("No .keys/");
544
+ });
545
+
546
+ it("outputs JSON for keys list when --json is set", async () => {
547
+ // First generate keys
548
+ const prog1 = makeProgram();
549
+ registerConfigCommand(prog1);
550
+ const c1 = await runCommand(prog1, ["config", "keys", "generate"]);
551
+ c1.restore();
552
+
553
+ const prog2 = makeProgram();
554
+ registerConfigCommand(prog2);
555
+ const capture = await runCommand(prog2, [
556
+ "--json",
557
+ "config",
558
+ "keys",
559
+ "list",
560
+ ]);
561
+ const json = capture.json<{ keys: number; files: string[] }>();
562
+ capture.restore();
563
+
564
+ expect(json.keys).toBeGreaterThan(0);
565
+ expect(Array.isArray(json.files)).toBe(true);
566
+ });
567
+
568
+ it("generates hex-formatted keys of expected length", async () => {
569
+ const program = makeProgram();
570
+ registerConfigCommand(program);
571
+
572
+ const capture = await runCommand(program, ["config", "keys", "generate"]);
573
+ capture.restore();
574
+
575
+ const file = Bun.file(".keys/webhook_api_key.env");
576
+ expect(await file.exists()).toBe(true);
577
+ const content = await file.text();
578
+ const match = content.match(/^WEBHOOK_API_KEY=([a-f0-9]+)\n$/);
579
+ expect(match).not.toBeNull();
580
+ // Service keys are 32 bytes = 64 hex chars
581
+ expect(match![1].length).toBe(64);
582
+ });
583
+ });