@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,10 @@
1
+ export { registerCheckCommand } from "./check-command.js";
2
+ export { registerPrerequisitesCommand } from "./prerequisites-command.js";
3
+ export type {
4
+ CheckResult,
5
+ CheckCategory,
6
+ CheckReport,
7
+ HealthCheckResult,
8
+ FixAction,
9
+ FixReport,
10
+ } from "./types.js";
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { runPrerequisitesCheck } from "./prerequisites-command.js";
3
+ import { PrerequisitesService } from "../../services/prerequisites/index.js";
4
+
5
+ describe("prerequisites command", () => {
6
+ describe("runPrerequisitesCheck", () => {
7
+ it("runs all checks and returns a report", async () => {
8
+ const svc = new PrerequisitesService();
9
+ const report = await runPrerequisitesCheck(svc);
10
+ expect(report.checks.length).toBeGreaterThan(0);
11
+ expect(typeof report.allPassed).toBe("boolean");
12
+ });
13
+
14
+ it("uses a new PrerequisitesService when none provided", async () => {
15
+ const report = await runPrerequisitesCheck();
16
+ expect(report.checks.length).toBeGreaterThan(0);
17
+ });
18
+ });
19
+ });
@@ -0,0 +1,92 @@
1
+ import { Command } from "commander";
2
+ import { PrerequisitesService } from "../../services/prerequisites/index.js";
3
+ import type { PrerequisitesReport } from "../../services/prerequisites/prerequisites-service.js";
4
+ import { theme, icons } from "../../utils/theme.js";
5
+ import { formatError, formatJson } from "../../utils/formatters.js";
6
+ import { CLIError, ExitCode } from "../../utils/errors.js";
7
+ import type { FormatOptions } from "../../utils/formatters.js";
8
+
9
+ export async function runPrerequisitesCheck(
10
+ service?: PrerequisitesService,
11
+ ): Promise<PrerequisitesReport> {
12
+ const svc = service ?? new PrerequisitesService();
13
+ return await svc.runAll();
14
+ }
15
+
16
+ function renderReport(report: PrerequisitesReport): void {
17
+ for (const check of report.checks) {
18
+ const icon = check.passed ? theme.success(icons.success) : theme.error(icons.error);
19
+ process.stdout.write(`${icon} ${check.name}\n`);
20
+ process.stdout.write(` ${theme.dim("Version:")} ${check.version}\n`);
21
+ process.stdout.write(` ${theme.dim("Required:")} ${check.required}\n`);
22
+ if (check.hint) {
23
+ process.stdout.write(` ${theme.warning(`${icons.warning} ${check.hint}`)}\n`);
24
+ }
25
+ process.stdout.write("\n");
26
+ }
27
+
28
+ const summary = report.allPassed
29
+ ? `${theme.success(icons.success)} All prerequisites met`
30
+ : `${theme.error(icons.error)} Some prerequisites not met`;
31
+ process.stdout.write(summary + "\n");
32
+ }
33
+
34
+ async function handlePrerequisites(
35
+ opts: FormatOptions,
36
+ tool?: string,
37
+ ): Promise<void> {
38
+ try {
39
+ const svc = new PrerequisitesService();
40
+ const report = tool ? await svc.runAll(tool) : await svc.runAll();
41
+
42
+ if (opts.json) {
43
+ formatJson(report, opts);
44
+ } else if (opts.quiet) {
45
+ } else {
46
+ renderReport(report);
47
+ }
48
+
49
+ if (!report.allPassed) {
50
+ process.exitCode = ExitCode.ERROR;
51
+ }
52
+ } catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ formatError(new CLIError(message, ExitCode.ERROR), opts);
55
+ process.exitCode = ExitCode.ERROR;
56
+ }
57
+ }
58
+
59
+ export function registerPrerequisitesCommand(parentCmd: Command): void {
60
+ parentCmd
61
+ .command("prerequisites")
62
+ .summary("Validate toolchain and account prerequisites")
63
+ .description(
64
+ `Check that all required tools and accounts are set up correctly.
65
+
66
+ Checks performed:
67
+ 1. Bun version (>=1.2)
68
+ 2. Git version (>=2.40)
69
+ 3. Node.js version (>=18, advisory)
70
+ 4. Wrangler CLI version (latest)
71
+ 5. Cloudflare authentication
72
+ 6. Docker availability (advisory)
73
+ 7. Repository integrity (wrangler.jsonc, submodules)
74
+
75
+ OPTIONS:
76
+ --tool <name> Only check a specific tool (e.g. "bun", "git", "wrangler")
77
+
78
+ EXAMPLES:
79
+ hoox check prerequisites
80
+ hoox check prerequisites --tool bun
81
+ hoox check prerequisites --json`,
82
+ )
83
+ .option("--tool <name>", "Only check a specific tool")
84
+ .action(async (options: { tool?: string }, cmd: Command) => {
85
+ const rootCmd = cmd.parent?.parent as Command | undefined;
86
+ const opts: FormatOptions = {
87
+ json: Boolean(rootCmd?.optsWithGlobals()?.json),
88
+ quiet: Boolean(rootCmd?.optsWithGlobals()?.quiet),
89
+ };
90
+ await handlePrerequisites(opts, options.tool);
91
+ });
92
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `hoox check` command types — validation check results, health status,
3
+ * and fix reports for the check command group.
4
+ */
5
+
6
+ /**
7
+ * Result of a single validation check within a category.
8
+ */
9
+ export interface CheckResult {
10
+ /** Human-readable check name (e.g. "D1 Database", "Wrangler Configs"). */
11
+ name: string;
12
+ /** Whether this check passed (no errors). */
13
+ success: boolean;
14
+ /** Failing conditions — must be addressed before deployment. */
15
+ errors: string[];
16
+ /** Non-blocking issues — should be reviewed. */
17
+ warnings: string[];
18
+ }
19
+
20
+ /**
21
+ * A logical group of related checks (Config, Infrastructure, Secrets, Database).
22
+ */
23
+ export interface CheckCategory {
24
+ /** Category label displayed in the report. */
25
+ name: string;
26
+ /** Per-check results within this category. */
27
+ checks: CheckResult[];
28
+ }
29
+
30
+ /**
31
+ * Top-level report produced by `hoox check setup`.
32
+ * Rendered as a table (human mode) or JSON (--json mode).
33
+ */
34
+ export interface CheckReport {
35
+ /** True when every check in every category passed (zero errors). */
36
+ success: boolean;
37
+ /** Grouped category results. */
38
+ categories: CheckCategory[];
39
+ /** Aggregated counts across all checks. */
40
+ summary: {
41
+ total: number;
42
+ passed: number;
43
+ failed: number;
44
+ warnings: number;
45
+ };
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Health check types
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Status of a single worker after a health probe.
54
+ */
55
+ export interface HealthCheckResult {
56
+ /** Worker name as defined in wrangler.jsonc. */
57
+ worker: string;
58
+ /** Categorised health status. */
59
+ status: "healthy" | "degraded" | "down";
60
+ /** Whether `wrangler tail` successfully connected to the worker. */
61
+ connectivity: boolean;
62
+ /** Approximate tail response time in ms (if measurable). */
63
+ responseTime?: number;
64
+ /** Human-readable error when status is "degraded" or "down". */
65
+ error?: string;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Fix / repair types
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * A single fix action to be applied (or simulated in --dry-run) by `hoox check fix`.
74
+ */
75
+ export interface FixAction {
76
+ description: string;
77
+ type: "file" | "binding" | "flag" | "config";
78
+ /** Target path or identifier (e.g. worker path, binding name). */
79
+ target: string;
80
+ /** Textual description of the exact change to apply. */
81
+ change: string;
82
+ /** Whether the fix was successfully applied (always false in --dry-run). */
83
+ applied: boolean;
84
+ /** Error message if application failed. */
85
+ error?: string;
86
+ }
87
+
88
+ /**
89
+ * Report produced by `hoox check fix` summarising all fix actions.
90
+ */
91
+ export interface FixReport {
92
+ /** Ordered list of fix actions. */
93
+ actions: FixAction[];
94
+ /** Whether this was a dry-run (no filesystem writes). */
95
+ dryRun: boolean;
96
+ /** Aggregated counts. */
97
+ summary: {
98
+ total: number;
99
+ applied: number;
100
+ skipped: number;
101
+ failed: number;
102
+ };
103
+ }
@@ -0,0 +1,442 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Unit tests for the clone command.
4
+ *
5
+ * Uses temp directories with real wrangler.jsonc and .git directories so
6
+ * ConfigService operates normally. Only Bun.spawn (git subcommands) is
7
+ * mocked to keep tests fast and side-effect free.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { Command } from "commander";
14
+ import { registerCloneCommand } from "./clone-command.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Bun.spawn mocking (same pattern as cloudflare-service.test.ts)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ type MockSpawnResult = {
21
+ stdout: Blob;
22
+ stderr: Blob;
23
+ exited: Promise<number>;
24
+ stdin?: { write: ReturnType<typeof mock>; end: ReturnType<typeof mock> };
25
+ kill: ReturnType<typeof mock>;
26
+ };
27
+
28
+ const realSpawn = Bun.spawn;
29
+ let origCwd: string;
30
+ let lastSpawnCmd: string[] = [];
31
+
32
+ function successSpawn(stdout = ""): MockSpawnResult {
33
+ return {
34
+ stdout: new Blob([stdout]),
35
+ stderr: new Blob([""]),
36
+ exited: Promise.resolve(0),
37
+ stdin: { write: mock(() => {}), end: mock(() => {}) },
38
+ kill: mock(() => {}),
39
+ };
40
+ }
41
+
42
+ function errorSpawn(stderr = "", exitCode = 1): MockSpawnResult {
43
+ return {
44
+ stdout: new Blob([""]),
45
+ stderr: new Blob([stderr]),
46
+ exited: Promise.resolve(exitCode),
47
+ stdin: { write: mock(() => {}), end: mock(() => {}) },
48
+ kill: mock(() => {}),
49
+ };
50
+ }
51
+
52
+ /** Queue of spawn results to return in order. */
53
+ let spawnQueue: MockSpawnResult[] = [];
54
+
55
+ function enqueueSpawn(result: MockSpawnResult): void {
56
+ spawnQueue.push(result);
57
+ }
58
+
59
+ function installSpawnMock(): void {
60
+ lastSpawnCmd = [];
61
+ const spawnMock = mock((cmd: string[], _opts?: { cwd?: string }) => {
62
+ lastSpawnCmd = [...cmd];
63
+ return spawnQueue.shift() ?? errorSpawn("unexpected spawn call", 127);
64
+ });
65
+ (Bun as Record<string, unknown>).spawn = spawnMock;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Test helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ let tmpDir: string;
73
+
74
+ /** Write a minimal wrangler.jsonc into tmpDir for ConfigService to load. */
75
+ async function writeTestConfig(
76
+ workers: Record<string, { enabled: boolean; path: string }>
77
+ ): Promise<string> {
78
+ const content = JSON.stringify({
79
+ global: { cloudflare_account_id: "test-account" },
80
+ workers,
81
+ });
82
+ await Bun.write(join(tmpDir, "wrangler.jsonc"), content);
83
+ return join(tmpDir, "wrangler.jsonc");
84
+ }
85
+
86
+ /** Create a real .git file to simulate an already-cloned git submodule. */
87
+ function markCloned(workerName: string, workerPath: string): void {
88
+ const workerDir = join(tmpDir, workerPath);
89
+ mkdirSync(workerDir, { recursive: true });
90
+ // Git submodules use a .git FILE (not directory) pointing to superproject
91
+ writeFileSync(
92
+ join(workerDir, ".git"),
93
+ "gitdir: ../.git/modules/" + workerName
94
+ );
95
+ }
96
+
97
+ /** Build a fresh commander program with the clone command registered. */
98
+ function buildProgram(): Command {
99
+ const program = new Command();
100
+ program.name("hoox");
101
+ program.option("--json", "JSON output");
102
+ program.option("--quiet", "Minimal output");
103
+ program.exitOverride();
104
+ registerCloneCommand(program);
105
+ return program;
106
+ }
107
+
108
+ /** Capture stdout during program.parseAsync, chdir'd to tmpDir first. */
109
+ async function captureStdout(
110
+ program: Command,
111
+ args: string[]
112
+ ): Promise<string> {
113
+ const origCwd = process.cwd();
114
+ process.chdir(tmpDir);
115
+
116
+ let output = "";
117
+ const orig = process.stdout.write.bind(process.stdout);
118
+ (process.stdout as Record<string, unknown>).write = mock((chunk: string) => {
119
+ output += chunk;
120
+ return true;
121
+ });
122
+
123
+ try {
124
+ await program.parseAsync([...args], { from: "user" });
125
+ } catch {
126
+ // exitOverride throws CommanderError — swallow
127
+ }
128
+
129
+ (process.stdout as Record<string, unknown>).write = orig;
130
+ process.chdir(origCwd);
131
+ return output;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Setup / Teardown
136
+ // ---------------------------------------------------------------------------
137
+
138
+ beforeEach(() => {
139
+ tmpDir = mkdtempSync(join(tmpdir(), "hoox-clone-test-"));
140
+ origCwd = process.cwd();
141
+ spawnQueue = [];
142
+ installSpawnMock();
143
+ });
144
+
145
+ afterEach(() => {
146
+ (Bun as Record<string, unknown>).spawn = realSpawn;
147
+ process.chdir(origCwd);
148
+ rmSync(tmpDir, { recursive: true, force: true });
149
+ mock.restore();
150
+ });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Tests
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe("registerCloneCommand", () => {
157
+ // -- Registration ----------------------------------------------------------
158
+
159
+ it("registers the clone command on the program", () => {
160
+ const program = buildProgram();
161
+ const names = program.commands.map((c) => c.name());
162
+ expect(names).toContain("clone");
163
+ });
164
+
165
+ it("clone command has --all and --org options", () => {
166
+ const program = buildProgram();
167
+ const cmd = program.commands.find((c) => c.name() === "clone");
168
+ expect(cmd).toBeDefined();
169
+ const opts = cmd!.options.map((o) => o.long);
170
+ expect(opts).toContain("--all");
171
+ expect(opts).toContain("--org");
172
+ });
173
+
174
+ it("clone command accepts optional [name] argument", () => {
175
+ const program = buildProgram();
176
+ const cmd = program.commands.find((c) => c.name() === "clone");
177
+ expect(cmd).toBeDefined();
178
+ const args = cmd!.registeredArguments;
179
+ expect(args).toHaveLength(1);
180
+ expect(args[0].name()).toBe("name");
181
+ expect(args[0].required).toBe(false);
182
+ });
183
+
184
+ // -- No-arg mode: list clone status ---------------------------------------
185
+
186
+ describe("no-arg mode (list status)", () => {
187
+ it("lists workers with clone status in table format", async () => {
188
+ // git remote detection
189
+ enqueueSpawn(successSpawn("https://github.com/test-org/hoox-setup.git"));
190
+
191
+ await writeTestConfig({
192
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
193
+ hoox: { enabled: true, path: "workers/hoox" },
194
+ });
195
+
196
+ const program = buildProgram();
197
+ const output = await captureStdout(program, ["clone"]);
198
+
199
+ expect(output).toContain("d1-worker");
200
+ expect(output).toContain("hoox");
201
+ expect(output).toContain("test-org");
202
+ });
203
+
204
+ it("marks already cloned workers", async () => {
205
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
206
+ await writeTestConfig({
207
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
208
+ "trade-worker": { enabled: true, path: "workers/trade-worker" },
209
+ });
210
+ markCloned("d1-worker", "workers/d1-worker");
211
+
212
+ const program = buildProgram();
213
+ const output = await captureStdout(program, ["clone"]);
214
+
215
+ // d1-worker should be "cloned", trade-worker should be "not cloned"
216
+ expect(output).toContain("cloned");
217
+ expect(output).toContain("not cloned");
218
+ });
219
+
220
+ it("supports --json flag for machine-readable output", async () => {
221
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
222
+
223
+ await writeTestConfig({
224
+ hoox: { enabled: true, path: "workers/hoox" },
225
+ });
226
+
227
+ const program = buildProgram();
228
+ const output = await captureStdout(program, ["--json", "clone"]);
229
+
230
+ const parsed = JSON.parse(output.trim());
231
+ expect(Array.isArray(parsed)).toBe(true);
232
+ expect(parsed[0]).toHaveProperty("worker");
233
+ expect(parsed[0]).toHaveProperty("cloned");
234
+ expect(parsed[0]).toHaveProperty("repo");
235
+ expect(parsed[0].worker).toBe("hoox");
236
+ });
237
+
238
+ it("supports --quiet flag with simple line-per-worker output", async () => {
239
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
240
+
241
+ await writeTestConfig({
242
+ hoox: { enabled: true, path: "workers/hoox" },
243
+ });
244
+
245
+ const program = buildProgram();
246
+ const output = await captureStdout(program, ["--quiet", "clone"]);
247
+
248
+ expect(output).toContain("hoox: not cloned");
249
+ expect(output).not.toContain("┌"); // no table borders
250
+ });
251
+ });
252
+
253
+ // -- --all mode ------------------------------------------------------------
254
+
255
+ describe("--all mode", () => {
256
+ it("clones all workers and prints summary table", async () => {
257
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
258
+ // git submodule add for d1-worker
259
+ enqueueSpawn(successSpawn());
260
+ // git submodule add for hoox
261
+ enqueueSpawn(successSpawn());
262
+ // git submodule update
263
+ enqueueSpawn(successSpawn());
264
+
265
+ await writeTestConfig({
266
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
267
+ hoox: { enabled: true, path: "workers/hoox" },
268
+ });
269
+
270
+ const program = buildProgram();
271
+ const output = await captureStdout(program, ["clone", "--all"]);
272
+
273
+ // Summary table is printed to stdout
274
+ expect(output).toContain("d1-worker");
275
+ expect(output).toContain("hoox");
276
+ expect(output).toContain("cloned");
277
+ });
278
+
279
+ it("skips already cloned workers", async () => {
280
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
281
+ // Only hoox needs cloning
282
+ enqueueSpawn(successSpawn());
283
+ enqueueSpawn(successSpawn());
284
+
285
+ await writeTestConfig({
286
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
287
+ hoox: { enabled: true, path: "workers/hoox" },
288
+ });
289
+ markCloned("d1-worker", "workers/d1-worker"); // already cloned
290
+
291
+ const program = buildProgram();
292
+ const output = await captureStdout(program, ["clone", "--all"]);
293
+
294
+ // Both workers appear in the table as "cloned"
295
+ expect(output).toContain("d1-worker");
296
+ expect(output).toContain("hoox");
297
+ });
298
+
299
+ it("reports failures when some clones fail", async () => {
300
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
301
+ // d1-worker succeeds
302
+ enqueueSpawn(successSpawn());
303
+ // hoox fails
304
+ enqueueSpawn(errorSpawn("repository not found", 128));
305
+ // submodule update (still runs)
306
+ enqueueSpawn(successSpawn());
307
+
308
+ await writeTestConfig({
309
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
310
+ hoox: { enabled: true, path: "workers/hoox" },
311
+ });
312
+
313
+ const program = buildProgram();
314
+ const output = await captureStdout(program, ["clone", "--all"]);
315
+
316
+ // Table shows both cloned and failed
317
+ expect(output).toContain("d1-worker");
318
+ expect(output).toContain("hoox");
319
+ expect(output).toContain("failed");
320
+ });
321
+
322
+ it("handles empty worker list", async () => {
323
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
324
+
325
+ await writeTestConfig({});
326
+
327
+ const program = buildProgram();
328
+ const output = await captureStdout(program, ["clone", "--all"]);
329
+
330
+ expect(output).toContain("No workers");
331
+ });
332
+ });
333
+
334
+ // -- <name> mode (single worker) -------------------------------------------
335
+
336
+ describe("<name> mode (single worker)", () => {
337
+ it("clones a specific worker by name", async () => {
338
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
339
+ enqueueSpawn(successSpawn());
340
+ enqueueSpawn(successSpawn());
341
+
342
+ await writeTestConfig({
343
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
344
+ });
345
+
346
+ const program = buildProgram();
347
+ const output = await captureStdout(program, ["clone", "d1-worker"]);
348
+
349
+ expect(output).toContain("Cloned d1-worker");
350
+ expect(output).toContain("org");
351
+ });
352
+
353
+ it("validates worker name exists in config", async () => {
354
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
355
+
356
+ await writeTestConfig({
357
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
358
+ });
359
+
360
+ const program = buildProgram();
361
+ const output = await captureStdout(program, ["clone", "nonexistent"]);
362
+
363
+ expect(output).toContain("not found");
364
+ });
365
+
366
+ it("exits early when worker is already cloned", async () => {
367
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
368
+ await writeTestConfig({
369
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
370
+ });
371
+ markCloned("d1-worker", "workers/d1-worker");
372
+
373
+ const program = buildProgram();
374
+ const output = await captureStdout(program, ["clone", "d1-worker"]);
375
+
376
+ expect(output).toContain("already cloned");
377
+ });
378
+
379
+ it("reports clone failure", async () => {
380
+ enqueueSpawn(successSpawn("https://github.com/org/repo.git"));
381
+ enqueueSpawn(errorSpawn("fatal: could not read from remote", 128));
382
+
383
+ await writeTestConfig({
384
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
385
+ });
386
+
387
+ const program = buildProgram();
388
+ const output = await captureStdout(program, ["clone", "d1-worker"]);
389
+
390
+ expect(output).toContain("Failed to clone");
391
+ });
392
+ });
393
+
394
+ // -- --org option ----------------------------------------------------------
395
+
396
+ describe("--org option", () => {
397
+ it("uses --org value for the repo URL", async () => {
398
+ // No git remote call needed since --org is explicit
399
+ enqueueSpawn(successSpawn());
400
+ enqueueSpawn(successSpawn());
401
+
402
+ await writeTestConfig({
403
+ hoox: { enabled: true, path: "workers/hoox" },
404
+ });
405
+
406
+ const program = buildProgram();
407
+ const output = await captureStdout(program, [
408
+ "clone",
409
+ "hoox",
410
+ "--org",
411
+ "custom-org",
412
+ ]);
413
+
414
+ expect(output).toContain("custom-org");
415
+ });
416
+ });
417
+
418
+ // -- Error handling --------------------------------------------------------
419
+
420
+ describe("error handling", () => {
421
+ it("handles missing wrangler.jsonc gracefully", async () => {
422
+ // Don't write a config file — ConfigService.load() will fail
423
+ const program = buildProgram();
424
+ const output = await captureStdout(program, ["clone"]);
425
+
426
+ expect(output).toContain("not found");
427
+ });
428
+
429
+ it("falls back to default org when git remote fails", async () => {
430
+ enqueueSpawn(errorSpawn("fatal: not a git repository", 128));
431
+
432
+ await writeTestConfig({
433
+ "d1-worker": { enabled: true, path: "workers/d1-worker" },
434
+ });
435
+
436
+ const program = buildProgram();
437
+ const output = await captureStdout(program, ["clone"]);
438
+
439
+ expect(output).toContain("jango-blockchained");
440
+ });
441
+ });
442
+ });