@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.
- package/README.md +403 -0
- package/bin/hoox.js +12 -0
- package/package.json +60 -0
- package/src/commands/check/check-command.test.ts +468 -0
- package/src/commands/check/check-command.ts +1144 -0
- package/src/commands/check/index.ts +10 -0
- package/src/commands/check/prerequisites-command.test.ts +19 -0
- package/src/commands/check/prerequisites-command.ts +92 -0
- package/src/commands/check/types.ts +103 -0
- package/src/commands/clone/clone-command.test.ts +442 -0
- package/src/commands/clone/clone-command.ts +440 -0
- package/src/commands/clone/index.ts +1 -0
- package/src/commands/config/config-command.test.ts +583 -0
- package/src/commands/config/config-command.ts +901 -0
- package/src/commands/config/env-command.test.ts +43 -0
- package/src/commands/config/env-command.ts +314 -0
- package/src/commands/config/index.ts +3 -0
- package/src/commands/config/kv-command.test.ts +14 -0
- package/src/commands/config/kv-command.ts +329 -0
- package/src/commands/dashboard/dashboard-command.test.ts +47 -0
- package/src/commands/dashboard/dashboard-command.ts +127 -0
- package/src/commands/dashboard/index.ts +1 -0
- package/src/commands/db/db-command.test.ts +21 -0
- package/src/commands/db/db-command.ts +314 -0
- package/src/commands/db/index.ts +1 -0
- package/src/commands/deploy/deploy-command.test.ts +304 -0
- package/src/commands/deploy/deploy-command.ts +1053 -0
- package/src/commands/deploy/index.ts +2 -0
- package/src/commands/deploy/telegram-service.ts +61 -0
- package/src/commands/deploy/types.ts +34 -0
- package/src/commands/dev/dev-command.test.ts +383 -0
- package/src/commands/dev/dev-command.ts +407 -0
- package/src/commands/dev/index.ts +1 -0
- package/src/commands/infra/index.ts +5 -0
- package/src/commands/infra/infra-command.test.ts +719 -0
- package/src/commands/infra/infra-command.ts +940 -0
- package/src/commands/infra/types.ts +23 -0
- package/src/commands/init/index.ts +1 -0
- package/src/commands/init/init-command.test.ts +827 -0
- package/src/commands/init/init-command.ts +627 -0
- package/src/commands/init/types.ts +185 -0
- package/src/commands/monitor/index.ts +2 -0
- package/src/commands/monitor/monitor-command.test.ts +235 -0
- package/src/commands/monitor/monitor-command.ts +245 -0
- package/src/commands/monitor/monitor-service.ts +50 -0
- package/src/commands/monitor/types.ts +13 -0
- package/src/commands/repair/index.ts +2 -0
- package/src/commands/repair/repair-command.test.ts +204 -0
- package/src/commands/repair/repair-command.ts +199 -0
- package/src/commands/repair/repair-service.ts +102 -0
- package/src/commands/repair/types.ts +13 -0
- package/src/commands/test/index.ts +2 -0
- package/src/commands/test/test-command.test.ts +319 -0
- package/src/commands/test/test-command.ts +412 -0
- package/src/commands/waf/index.ts +2 -0
- package/src/commands/waf/types.ts +48 -0
- package/src/commands/waf/waf-command.test.ts +506 -0
- package/src/commands/waf/waf-command.ts +548 -0
- package/src/index.ts +198 -0
- package/src/services/cloudflare/cloudflare-service.test.ts +654 -0
- package/src/services/cloudflare/cloudflare-service.ts +435 -0
- package/src/services/cloudflare/index.ts +2 -0
- package/src/services/cloudflare/types.ts +29 -0
- package/src/services/config/config-service.test.ts +395 -0
- package/src/services/config/config-service.ts +207 -0
- package/src/services/config/index.ts +9 -0
- package/src/services/config/types.ts +66 -0
- package/src/services/db/db-service.test.ts +51 -0
- package/src/services/db/db-service.ts +140 -0
- package/src/services/db/index.ts +1 -0
- package/src/services/docker/docker-service.ts +155 -0
- package/src/services/docker/index.ts +1 -0
- package/src/services/env/env-service.test.ts +210 -0
- package/src/services/env/env-service.ts +156 -0
- package/src/services/env/index.ts +1 -0
- package/src/services/kv/index.ts +1 -0
- package/src/services/kv/kv-sync-service.test.ts +38 -0
- package/src/services/kv/kv-sync-service.ts +151 -0
- package/src/services/prerequisites/index.ts +1 -0
- package/src/services/prerequisites/prerequisites-service.test.ts +89 -0
- package/src/services/prerequisites/prerequisites-service.ts +269 -0
- package/src/services/prerequisites/types.ts +48 -0
- package/src/services/secrets/index.ts +12 -0
- package/src/services/secrets/secrets-service.test.ts +486 -0
- package/src/services/secrets/secrets-service.ts +293 -0
- package/src/services/secrets/types.ts +57 -0
- package/src/ui/banner.ts +52 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/menu.ts +473 -0
- package/src/utils/errors.test.ts +69 -0
- package/src/utils/errors.ts +23 -0
- package/src/utils/formatters.test.ts +180 -0
- package/src/utils/formatters.ts +252 -0
- package/src/utils/theme.ts +94 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hoox init` command.
|
|
3
|
+
*
|
|
4
|
+
* Mocks:
|
|
5
|
+
* - @clack/prompts → controllable prompt responses
|
|
6
|
+
* - CloudflareService → simulated wrangler output
|
|
7
|
+
* - Bun.write → captured file writes
|
|
8
|
+
* - process.exit → captured exit codes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// @ts-nocheck — spyOn on module namespace requires type bypasses for strict signatures
|
|
12
|
+
import {
|
|
13
|
+
afterEach,
|
|
14
|
+
beforeEach,
|
|
15
|
+
describe,
|
|
16
|
+
expect,
|
|
17
|
+
it,
|
|
18
|
+
mock,
|
|
19
|
+
spyOn,
|
|
20
|
+
} from "bun:test";
|
|
21
|
+
import { runInitCommand } from "./init-command.js";
|
|
22
|
+
import type { InitOptions } from "./types.js";
|
|
23
|
+
import { ExitCode } from "../../utils/errors.js";
|
|
24
|
+
import { CloudflareService } from "../../services/cloudflare/cloudflare-service.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Mock types / helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Store captured calls so tests can assert on behaviour. */
|
|
31
|
+
interface CapturedCalls {
|
|
32
|
+
intro: string[];
|
|
33
|
+
outro: string[];
|
|
34
|
+
note: { title: string; content: string }[];
|
|
35
|
+
passwordMessages: string[];
|
|
36
|
+
textMessages: string[];
|
|
37
|
+
multiselectMessages: string[];
|
|
38
|
+
confirmMessages: string[];
|
|
39
|
+
cancelMessages: string[];
|
|
40
|
+
logInfo: string[];
|
|
41
|
+
logStep: string[];
|
|
42
|
+
logSuccess: string[];
|
|
43
|
+
logWarn: string[];
|
|
44
|
+
logError: string[];
|
|
45
|
+
/** Written files: path → content */
|
|
46
|
+
writes: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeCapture(): CapturedCalls {
|
|
50
|
+
return {
|
|
51
|
+
intro: [],
|
|
52
|
+
outro: [],
|
|
53
|
+
note: [],
|
|
54
|
+
passwordMessages: [],
|
|
55
|
+
textMessages: [],
|
|
56
|
+
multiselectMessages: [],
|
|
57
|
+
confirmMessages: [],
|
|
58
|
+
cancelMessages: [],
|
|
59
|
+
logInfo: [],
|
|
60
|
+
logStep: [],
|
|
61
|
+
logSuccess: [],
|
|
62
|
+
logWarn: [],
|
|
63
|
+
logError: [],
|
|
64
|
+
writes: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let captured: CapturedCalls;
|
|
69
|
+
|
|
70
|
+
/** Configurable responses for each prompt type. */
|
|
71
|
+
interface PromptResponses {
|
|
72
|
+
password?: string | symbol;
|
|
73
|
+
text?: string | symbol;
|
|
74
|
+
multiselect?: string[] | symbol;
|
|
75
|
+
confirm?: boolean | symbol;
|
|
76
|
+
/** Group responses keyed by field name */
|
|
77
|
+
group?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const defaultResponses: Required<PromptResponses> = {
|
|
81
|
+
password: "test-token",
|
|
82
|
+
text: "test-account",
|
|
83
|
+
multiselect: [],
|
|
84
|
+
confirm: true,
|
|
85
|
+
group: {},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let responses: PromptResponses = { ...defaultResponses };
|
|
89
|
+
|
|
90
|
+
/** Control how many times each prompt type has been called. */
|
|
91
|
+
interface CallCounters {
|
|
92
|
+
password: number;
|
|
93
|
+
text: number;
|
|
94
|
+
multiselect: number;
|
|
95
|
+
confirm: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let counters: CallCounters = {
|
|
99
|
+
password: 0,
|
|
100
|
+
text: 0,
|
|
101
|
+
multiselect: 0,
|
|
102
|
+
confirm: 0,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function resetCounters(): void {
|
|
106
|
+
counters = { password: 0, text: 0, multiselect: 0, confirm: 0 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether to simulate cancellation on next check. */
|
|
110
|
+
let simulateCancel = false;
|
|
111
|
+
|
|
112
|
+
/** Custom password responder — set per-test for sequenced token responses. */
|
|
113
|
+
let customPasswordResponder: ((msg: string) => string | symbol) | null = null;
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Mock: CloudflareService (prototype based — no mock.module, no leakage)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const origWhoami = CloudflareService.prototype.whoami;
|
|
120
|
+
|
|
121
|
+
const mockWhoami = mock(
|
|
122
|
+
async (): Promise<
|
|
123
|
+
{ ok: true; data: string } | { ok: false; error: string }
|
|
124
|
+
> => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
data: "user@example.com",
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Mock: @clack/prompts (import namespace + spyOn, not mock.module)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
import * as clack from "@clack/prompts";
|
|
135
|
+
|
|
136
|
+
function installClackSpies(): void {
|
|
137
|
+
spyOn(clack, "intro").mockImplementation((msg: string) => {
|
|
138
|
+
captured.intro.push(msg);
|
|
139
|
+
});
|
|
140
|
+
spyOn(clack, "outro").mockImplementation((msg: string) => {
|
|
141
|
+
captured.outro.push(msg);
|
|
142
|
+
});
|
|
143
|
+
spyOn(clack, "note").mockImplementation((content: string, title?: string) => {
|
|
144
|
+
captured.note.push({ title: title ?? "", content });
|
|
145
|
+
});
|
|
146
|
+
spyOn(clack, "password").mockImplementation(
|
|
147
|
+
async (opts: {
|
|
148
|
+
message: string;
|
|
149
|
+
validate?: (v: string) => string | void;
|
|
150
|
+
}) => {
|
|
151
|
+
captured.passwordMessages.push(opts.message);
|
|
152
|
+
counters.password++;
|
|
153
|
+
if (simulateCancel) return Symbol.for("clack.cancel");
|
|
154
|
+
if (customPasswordResponder) return customPasswordResponder(opts.message);
|
|
155
|
+
return responses.password ?? defaultResponses.password;
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
spyOn(clack, "text").mockImplementation(
|
|
159
|
+
async (opts: {
|
|
160
|
+
message: string;
|
|
161
|
+
placeholder?: string;
|
|
162
|
+
defaultValue?: string;
|
|
163
|
+
validate?: (v: string) => string | void;
|
|
164
|
+
}) => {
|
|
165
|
+
captured.textMessages.push(opts.message);
|
|
166
|
+
counters.text++;
|
|
167
|
+
return simulateCancel
|
|
168
|
+
? Symbol.for("clack.cancel")
|
|
169
|
+
: (responses.text ?? defaultResponses.text);
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
spyOn(clack, "multiselect").mockImplementation(
|
|
173
|
+
async (opts: {
|
|
174
|
+
message: string;
|
|
175
|
+
options: { value: string; label: string; hint?: string }[];
|
|
176
|
+
required?: boolean;
|
|
177
|
+
}) => {
|
|
178
|
+
captured.multiselectMessages.push(opts.message);
|
|
179
|
+
counters.multiselect++;
|
|
180
|
+
return simulateCancel
|
|
181
|
+
? Symbol.for("clack.cancel")
|
|
182
|
+
: (responses.multiselect ?? defaultResponses.multiselect);
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
spyOn(clack, "confirm").mockImplementation(
|
|
186
|
+
async (opts: { message: string; initialValue?: boolean }) => {
|
|
187
|
+
captured.confirmMessages.push(opts.message);
|
|
188
|
+
counters.confirm++;
|
|
189
|
+
return simulateCancel
|
|
190
|
+
? Symbol.for("clack.cancel")
|
|
191
|
+
: (responses.confirm ?? defaultResponses.confirm);
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
spyOn(clack, "group").mockImplementation(
|
|
195
|
+
async (
|
|
196
|
+
fields: Record<string, () => Promise<string | symbol>>,
|
|
197
|
+
groupOpts?: { onCancel?: () => void }
|
|
198
|
+
) => {
|
|
199
|
+
const results: Record<string, string> = {};
|
|
200
|
+
for (const [key, fn] of Object.entries(fields)) {
|
|
201
|
+
if (simulateCancel) {
|
|
202
|
+
if (groupOpts?.onCancel) groupOpts.onCancel();
|
|
203
|
+
return Symbol.for("clack.cancel");
|
|
204
|
+
}
|
|
205
|
+
const val = await fn();
|
|
206
|
+
results[key] =
|
|
207
|
+
responses.group?.[key] !== undefined
|
|
208
|
+
? responses.group[key]
|
|
209
|
+
: typeof val === "string"
|
|
210
|
+
? val
|
|
211
|
+
: "";
|
|
212
|
+
}
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
spyOn(clack, "isCancel").mockImplementation((value: unknown) => {
|
|
217
|
+
return (
|
|
218
|
+
simulateCancel ||
|
|
219
|
+
(typeof value === "symbol" &&
|
|
220
|
+
Symbol.keyFor(value as symbol) === "clack.cancel")
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
spyOn(clack, "cancel").mockImplementation((msg: string) => {
|
|
224
|
+
captured.cancelMessages.push(msg);
|
|
225
|
+
});
|
|
226
|
+
spyOn(clack.log, "info").mockImplementation((msg: string) =>
|
|
227
|
+
captured.logInfo.push(msg)
|
|
228
|
+
);
|
|
229
|
+
spyOn(clack.log, "step").mockImplementation((msg: string) =>
|
|
230
|
+
captured.logStep.push(msg)
|
|
231
|
+
);
|
|
232
|
+
spyOn(clack.log, "success").mockImplementation((msg: string) =>
|
|
233
|
+
captured.logSuccess.push(msg)
|
|
234
|
+
);
|
|
235
|
+
spyOn(clack.log, "warn").mockImplementation((msg: string) =>
|
|
236
|
+
captured.logWarn.push(msg)
|
|
237
|
+
);
|
|
238
|
+
spyOn(clack.log, "error").mockImplementation((msg: string) =>
|
|
239
|
+
captured.logError.push(msg)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Mock: Bun.write & Bun.file
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const originalWrite = Bun.write;
|
|
248
|
+
const originalFile = Bun.file;
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
captured = makeCapture();
|
|
252
|
+
responses = { ...defaultResponses };
|
|
253
|
+
resetCounters();
|
|
254
|
+
simulateCancel = false;
|
|
255
|
+
customPasswordResponder = null;
|
|
256
|
+
mockWhoami.mockImplementation(async () => ({
|
|
257
|
+
ok: true,
|
|
258
|
+
data: "user@example.com",
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
// Mock: CloudflareService prototype
|
|
262
|
+
CloudflareService.prototype.whoami = mockWhoami;
|
|
263
|
+
|
|
264
|
+
// Mock: @clack/prompts via spyOn (scoped, restorable)
|
|
265
|
+
installClackSpies();
|
|
266
|
+
|
|
267
|
+
// Mock Bun.write to capture all file writes
|
|
268
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
269
|
+
spyOn(Bun, "write" as any).mockImplementation(
|
|
270
|
+
async (path: string | URL, content: string | Uint8Array) => {
|
|
271
|
+
captured.writes[String(path)] =
|
|
272
|
+
typeof content === "string"
|
|
273
|
+
? content
|
|
274
|
+
: new TextDecoder().decode(content);
|
|
275
|
+
return typeof content === "string" ? content.length : content.byteLength;
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Mock Bun.file to simulate filesystem (no existing wrangler.jsonc by default)
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
281
|
+
spyOn(Bun, "file" as any).mockImplementation(
|
|
282
|
+
(_path: string | URL) =>
|
|
283
|
+
({
|
|
284
|
+
exists: async () => false,
|
|
285
|
+
text: async () => "",
|
|
286
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
287
|
+
json: async () => ({}),
|
|
288
|
+
size: 0,
|
|
289
|
+
name: String(_path),
|
|
290
|
+
lastModified: 0,
|
|
291
|
+
slice: () => new Blob(),
|
|
292
|
+
stream: () => new ReadableStream(),
|
|
293
|
+
type: "",
|
|
294
|
+
}) as unknown as Bun.BunFile
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Mock process.exit — no-op to prevent actual test termination
|
|
298
|
+
spyOn(process, "exit").mockImplementation(((_code?: number) => {
|
|
299
|
+
// no-op: prevent actual exit
|
|
300
|
+
}) as never);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
mock.restore();
|
|
305
|
+
CloudflareService.prototype.whoami = origWhoami;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Tests
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe("init command", () => {
|
|
313
|
+
// ------------------------------------------------------------------
|
|
314
|
+
// Interactive flow
|
|
315
|
+
// ------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
describe("interactive flow", () => {
|
|
318
|
+
it("shows intro and outro messages", async () => {
|
|
319
|
+
responses = {
|
|
320
|
+
password: "valid-token",
|
|
321
|
+
text: "test-account-id",
|
|
322
|
+
multiselect: [],
|
|
323
|
+
confirm: false,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Prevent collectBaseSecrets confirm from breaking
|
|
327
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
328
|
+
|
|
329
|
+
expect(captured.intro.length).toBeGreaterThan(0);
|
|
330
|
+
expect(captured.intro.some((m) => m.includes("Hoox Setup Wizard"))).toBe(
|
|
331
|
+
true
|
|
332
|
+
);
|
|
333
|
+
// Outro may or may not fire depending on flow. If confirm returns false,
|
|
334
|
+
// the base secrets confirm gets captured but the flow continues.
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("collects Cloudflare API token with validation", async () => {
|
|
338
|
+
responses = {
|
|
339
|
+
password: "valid-token",
|
|
340
|
+
text: "test-account-id",
|
|
341
|
+
multiselect: [],
|
|
342
|
+
confirm: false,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
346
|
+
|
|
347
|
+
expect(captured.passwordMessages).toContain("Cloudflare API token:");
|
|
348
|
+
expect(mockWhoami).toHaveBeenCalled();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("retries on invalid token (validation error)", async () => {
|
|
352
|
+
let callCount = 0;
|
|
353
|
+
mockWhoami.mockImplementation(async () => {
|
|
354
|
+
callCount++;
|
|
355
|
+
if (callCount === 1) {
|
|
356
|
+
return { ok: false as const, error: "Invalid credentials" };
|
|
357
|
+
}
|
|
358
|
+
return { ok: true as const, data: "user@example.com" };
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// First password call returns bad token, second returns good token
|
|
362
|
+
let pwdCount = 0;
|
|
363
|
+
customPasswordResponder = (_msg: string) => {
|
|
364
|
+
pwdCount++;
|
|
365
|
+
return pwdCount === 1 ? "bad-token" : "good-token";
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
responses = {
|
|
369
|
+
password: "",
|
|
370
|
+
text: "test-account-id",
|
|
371
|
+
multiselect: [],
|
|
372
|
+
confirm: false,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
376
|
+
|
|
377
|
+
// Should have called password at least twice (first failed, retry)
|
|
378
|
+
expect(pwdCount).toBeGreaterThanOrEqual(2);
|
|
379
|
+
expect(captured.logError.length).toBeGreaterThan(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("collects account ID with default from existing wrangler.jsonc", async () => {
|
|
383
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
384
|
+
const fileSpy = spyOn(Bun, "file" as any).mockImplementation(
|
|
385
|
+
(path: string) => {
|
|
386
|
+
if (path === "wrangler.jsonc") {
|
|
387
|
+
return {
|
|
388
|
+
exists: async () => true,
|
|
389
|
+
text: async () =>
|
|
390
|
+
'{"global":{"cloudflare_account_id":"existing-account-id-123"}}',
|
|
391
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
392
|
+
json: async () => ({}),
|
|
393
|
+
size: 0,
|
|
394
|
+
name: "",
|
|
395
|
+
lastModified: 0,
|
|
396
|
+
slice: () => new Blob(),
|
|
397
|
+
stream: () => new ReadableStream(),
|
|
398
|
+
type: "",
|
|
399
|
+
} as unknown as Bun.BunFile;
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
exists: async () => false,
|
|
403
|
+
text: async () => "",
|
|
404
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
405
|
+
json: async () => ({}),
|
|
406
|
+
size: 0,
|
|
407
|
+
name: "",
|
|
408
|
+
lastModified: 0,
|
|
409
|
+
slice: () => new Blob(),
|
|
410
|
+
stream: () => new ReadableStream(),
|
|
411
|
+
type: "",
|
|
412
|
+
} as unknown as Bun.BunFile;
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
responses = {
|
|
417
|
+
password: "valid-token",
|
|
418
|
+
text: "existing-account-id-123",
|
|
419
|
+
multiselect: [],
|
|
420
|
+
confirm: false,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
424
|
+
|
|
425
|
+
expect(captured.textMessages.some((m) => m.includes("Account ID"))).toBe(
|
|
426
|
+
true
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
fileSpy.mockRestore();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("collects integrations via multiselect", async () => {
|
|
433
|
+
responses = {
|
|
434
|
+
password: "valid-token",
|
|
435
|
+
text: "test-account-id",
|
|
436
|
+
multiselect: ["binance", "telegram"],
|
|
437
|
+
confirm: false,
|
|
438
|
+
group: {
|
|
439
|
+
BINANCE_API_KEY: "binance-key-123",
|
|
440
|
+
BINANCE_API_SECRET: "binance-secret-123",
|
|
441
|
+
TELEGRAM_BOT_TOKEN: "tg-bot-token-123",
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
446
|
+
|
|
447
|
+
expect(
|
|
448
|
+
captured.multiselectMessages.some((m) =>
|
|
449
|
+
m.includes("Select integrations")
|
|
450
|
+
)
|
|
451
|
+
).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("collects per-integration secrets for selected integrations", async () => {
|
|
455
|
+
responses = {
|
|
456
|
+
password: "valid-token",
|
|
457
|
+
text: "test-account-id",
|
|
458
|
+
multiselect: ["telegram"],
|
|
459
|
+
confirm: false,
|
|
460
|
+
group: {
|
|
461
|
+
TELEGRAM_BOT_TOKEN: "tg-token-abc",
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
466
|
+
|
|
467
|
+
// Password prompts should include Telegram Bot Token
|
|
468
|
+
expect(
|
|
469
|
+
captured.passwordMessages.some((m) => m.includes("Telegram Bot Token"))
|
|
470
|
+
).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ------------------------------------------------------------------
|
|
475
|
+
// Non-interactive flow
|
|
476
|
+
// ------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
describe("non-interactive flow", () => {
|
|
479
|
+
it("skips prompts when --token and --account are provided", async () => {
|
|
480
|
+
const options: InitOptions = {
|
|
481
|
+
token: "cf-token-non-interactive",
|
|
482
|
+
account: "cf-account-non-interactive",
|
|
483
|
+
secretStore: "ss-id-123",
|
|
484
|
+
prefix: "myprefix",
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
await runInitCommand(options, { json: false, quiet: true }, true);
|
|
488
|
+
|
|
489
|
+
// No interactive prompts should have been shown
|
|
490
|
+
expect(captured.passwordMessages.length).toBe(0);
|
|
491
|
+
expect(captured.textMessages.length).toBe(0);
|
|
492
|
+
expect(captured.multiselectMessages.length).toBe(0);
|
|
493
|
+
|
|
494
|
+
// Token should have been validated
|
|
495
|
+
expect(mockWhoami).toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("validates token in non-interactive mode", async () => {
|
|
499
|
+
mockWhoami.mockImplementation(async () => ({
|
|
500
|
+
ok: false as const,
|
|
501
|
+
error: "Bad token",
|
|
502
|
+
}));
|
|
503
|
+
|
|
504
|
+
// Override process.exit to capture and throw (halts execution)
|
|
505
|
+
let exitCode = -1;
|
|
506
|
+
process.exit = mock((code?: number) => {
|
|
507
|
+
exitCode = code ?? -1;
|
|
508
|
+
throw new Error("EXIT");
|
|
509
|
+
}) as unknown as typeof process.exit;
|
|
510
|
+
|
|
511
|
+
const options: InitOptions = {
|
|
512
|
+
token: "bad-token",
|
|
513
|
+
account: "account-123",
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const promise = runInitCommand(
|
|
517
|
+
options,
|
|
518
|
+
{ json: false, quiet: true },
|
|
519
|
+
true
|
|
520
|
+
);
|
|
521
|
+
await promise.catch(() => {
|
|
522
|
+
/* expected */
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(exitCode).toBe(ExitCode.ERROR);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("writes wrangler.jsonc in non-interactive mode", async () => {
|
|
529
|
+
const options: InitOptions = {
|
|
530
|
+
token: "cf-token-ni",
|
|
531
|
+
account: "cf-account-ni",
|
|
532
|
+
secretStore: "ss-ni",
|
|
533
|
+
prefix: "ni-prefix",
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
await runInitCommand(options, { json: false, quiet: true }, true);
|
|
537
|
+
|
|
538
|
+
// Verify wrangler.jsonc was written
|
|
539
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
540
|
+
expect(workersJsonc).toBeDefined();
|
|
541
|
+
expect(workersJsonc).toContain("cf-token-ni");
|
|
542
|
+
expect(workersJsonc).toContain("cf-account-ni");
|
|
543
|
+
expect(workersJsonc).toContain("ss-ni");
|
|
544
|
+
expect(workersJsonc).toContain("ni-prefix");
|
|
545
|
+
expect(workersJsonc).toContain("d1-worker");
|
|
546
|
+
expect(workersJsonc).toContain("hoox");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("uses default prefix 'cryptolinx' when --prefix not provided", async () => {
|
|
550
|
+
const options: InitOptions = {
|
|
551
|
+
token: "cf-token-def",
|
|
552
|
+
account: "cf-account-def",
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
await runInitCommand(options, { json: false, quiet: true }, true);
|
|
556
|
+
|
|
557
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
558
|
+
expect(workersJsonc).toContain("cryptolinx");
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ------------------------------------------------------------------
|
|
563
|
+
// wrangler.jsonc content verification
|
|
564
|
+
// ------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
describe("wrangler.jsonc output", () => {
|
|
567
|
+
it("includes base workers (d1-worker, hoox, agent-worker, analytics-worker)", async () => {
|
|
568
|
+
responses = {
|
|
569
|
+
password: "valid-token",
|
|
570
|
+
text: "test-account",
|
|
571
|
+
multiselect: [],
|
|
572
|
+
confirm: false,
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
576
|
+
|
|
577
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
578
|
+
expect(workersJsonc).toContain('"d1-worker"');
|
|
579
|
+
expect(workersJsonc).toContain('"hoox"');
|
|
580
|
+
expect(workersJsonc).toContain('"agent-worker"');
|
|
581
|
+
expect(workersJsonc).toContain('"analytics-worker"');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("includes integration workers when selected", async () => {
|
|
585
|
+
responses = {
|
|
586
|
+
password: "valid-token",
|
|
587
|
+
text: "test-account",
|
|
588
|
+
multiselect: ["binance", "telegram"],
|
|
589
|
+
confirm: false,
|
|
590
|
+
group: {
|
|
591
|
+
BINANCE_API_KEY: "bk",
|
|
592
|
+
BINANCE_API_SECRET: "bs",
|
|
593
|
+
TELEGRAM_BOT_TOKEN: "tgt",
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
598
|
+
|
|
599
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
600
|
+
expect(workersJsonc).toContain('"trade-worker"');
|
|
601
|
+
expect(workersJsonc).toContain('"BINANCE_API_KEY"');
|
|
602
|
+
expect(workersJsonc).toContain('"BINANCE_API_SECRET"');
|
|
603
|
+
expect(workersJsonc).toContain('"telegram-worker"');
|
|
604
|
+
expect(workersJsonc).toContain('"TELEGRAM_BOT_TOKEN"');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("includes AI provider integrations when selected", async () => {
|
|
608
|
+
responses = {
|
|
609
|
+
password: "valid-token",
|
|
610
|
+
text: "test-account",
|
|
611
|
+
multiselect: ["openai", "anthropic", "google-ai"],
|
|
612
|
+
confirm: false,
|
|
613
|
+
group: {
|
|
614
|
+
AGENT_OPENAI_KEY: "sk-openai-123",
|
|
615
|
+
AGENT_ANTHROPIC_KEY: "sk-ant-123",
|
|
616
|
+
AGENT_GOOGLE_KEY: "google-ai-key-456",
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
621
|
+
|
|
622
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
623
|
+
expect(workersJsonc).toContain('"agent-worker"');
|
|
624
|
+
expect(workersJsonc).toContain('"AGENT_OPENAI_KEY"');
|
|
625
|
+
expect(workersJsonc).toContain('"AGENT_ANTHROPIC_KEY"');
|
|
626
|
+
expect(workersJsonc).toContain('"AGENT_GOOGLE_KEY"');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("includes Home Assistant integration when selected", async () => {
|
|
630
|
+
responses = {
|
|
631
|
+
password: "valid-token",
|
|
632
|
+
text: "test-account",
|
|
633
|
+
multiselect: ["home-assistant"],
|
|
634
|
+
confirm: false,
|
|
635
|
+
group: {
|
|
636
|
+
HA_TOKEN_BINDING: "ha-token-789",
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
641
|
+
|
|
642
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
643
|
+
expect(workersJsonc).toContain('"hoox"');
|
|
644
|
+
expect(workersJsonc).toContain('"HA_TOKEN_BINDING"');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("merges exchange secrets into single trade-worker", async () => {
|
|
648
|
+
responses = {
|
|
649
|
+
password: "valid-token",
|
|
650
|
+
text: "test-account",
|
|
651
|
+
multiselect: ["binance", "mexc"],
|
|
652
|
+
confirm: false,
|
|
653
|
+
group: {
|
|
654
|
+
BINANCE_API_KEY: "bk",
|
|
655
|
+
BINANCE_API_SECRET: "bs",
|
|
656
|
+
MEXC_API_KEY: "mk",
|
|
657
|
+
MEXC_API_SECRET: "ms",
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
662
|
+
|
|
663
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
664
|
+
expect(workersJsonc).toContain('"trade-worker"');
|
|
665
|
+
expect(workersJsonc).toContain('"BINANCE_API_KEY"');
|
|
666
|
+
expect(workersJsonc).toContain('"MEXC_API_KEY"');
|
|
667
|
+
// Should only appear once
|
|
668
|
+
const tradeWorkerCount =
|
|
669
|
+
workersJsonc.match(/"trade-worker"/g)?.length ?? 0;
|
|
670
|
+
expect(tradeWorkerCount).toBe(1);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("includes global config section", async () => {
|
|
674
|
+
responses = {
|
|
675
|
+
password: "my-cf-token",
|
|
676
|
+
text: "my-account-id",
|
|
677
|
+
multiselect: [],
|
|
678
|
+
confirm: false,
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
682
|
+
|
|
683
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
684
|
+
expect(workersJsonc).toContain('"global"');
|
|
685
|
+
expect(workersJsonc).toContain('"cloudflare_api_token"');
|
|
686
|
+
expect(workersJsonc).toContain('"cloudflare_account_id"');
|
|
687
|
+
expect(workersJsonc).toContain('"my-account-id"');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("has JSONC header comment", async () => {
|
|
691
|
+
responses = {
|
|
692
|
+
password: "valid-token",
|
|
693
|
+
text: "test-account",
|
|
694
|
+
multiselect: [],
|
|
695
|
+
confirm: false,
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
699
|
+
|
|
700
|
+
const workersJsonc = captured.writes["wrangler.jsonc"];
|
|
701
|
+
expect(workersJsonc).toContain("// Hoox Workspace Configuration");
|
|
702
|
+
expect(workersJsonc).toContain("// Generated by `hoox init`");
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ------------------------------------------------------------------
|
|
707
|
+
// .dev.vars creation
|
|
708
|
+
// ------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
describe(".dev.vars creation", () => {
|
|
711
|
+
it("creates .dev.vars for integration workers", async () => {
|
|
712
|
+
responses = {
|
|
713
|
+
password: "valid-token",
|
|
714
|
+
text: "test-account",
|
|
715
|
+
multiselect: ["telegram"],
|
|
716
|
+
confirm: false,
|
|
717
|
+
group: {
|
|
718
|
+
TELEGRAM_BOT_TOKEN: "tg-secret-value",
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
723
|
+
|
|
724
|
+
const devVarsPath = "workers/telegram-worker/.dev.vars";
|
|
725
|
+
const devVars = captured.writes[devVarsPath];
|
|
726
|
+
expect(devVars).toContain("TELEGRAM_BOT_TOKEN=tg-secret-value");
|
|
727
|
+
expect(devVars).toContain("NEVER commit this file");
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("does not create .dev.vars for workers without secrets", async () => {
|
|
731
|
+
responses = {
|
|
732
|
+
password: "valid-token",
|
|
733
|
+
text: "test-account",
|
|
734
|
+
multiselect: [],
|
|
735
|
+
confirm: false,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
739
|
+
|
|
740
|
+
// d1-worker has no user-collected secrets in interactive mode
|
|
741
|
+
const d1DevVars = captured.writes["workers/d1-worker/.dev.vars"];
|
|
742
|
+
expect(d1DevVars).toBeUndefined();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("creates .dev.vars for agent-worker when an AI provider is selected", async () => {
|
|
746
|
+
responses = {
|
|
747
|
+
password: "valid-token",
|
|
748
|
+
text: "test-account",
|
|
749
|
+
multiselect: ["openai"],
|
|
750
|
+
confirm: false,
|
|
751
|
+
group: {
|
|
752
|
+
AGENT_OPENAI_KEY: "sk-openai-real-value",
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
757
|
+
|
|
758
|
+
const devVarsPath = "workers/agent-worker/.dev.vars";
|
|
759
|
+
const devVars = captured.writes[devVarsPath];
|
|
760
|
+
expect(devVars).toContain("AGENT_OPENAI_KEY=sk-openai-real-value");
|
|
761
|
+
expect(devVars).toContain("NEVER commit this file");
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("creates .dev.vars for hoox when Home Assistant selected", async () => {
|
|
765
|
+
responses = {
|
|
766
|
+
password: "valid-token",
|
|
767
|
+
text: "test-account",
|
|
768
|
+
multiselect: ["home-assistant"],
|
|
769
|
+
confirm: false,
|
|
770
|
+
group: {
|
|
771
|
+
HA_TOKEN_BINDING: "ha-token-real-value",
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
776
|
+
|
|
777
|
+
const devVarsPath = "workers/hoox/.dev.vars";
|
|
778
|
+
const devVars = captured.writes[devVarsPath];
|
|
779
|
+
expect(devVars).toContain("HA_TOKEN_BINDING=ha-token-real-value");
|
|
780
|
+
expect(devVars).toContain("NEVER commit this file");
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ------------------------------------------------------------------
|
|
785
|
+
// Cancellation handling
|
|
786
|
+
// ------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
describe("cancellation handling", () => {
|
|
789
|
+
it("exits on cancel during token prompt", async () => {
|
|
790
|
+
// Override the process.exit mock to track calls
|
|
791
|
+
let exitCalled = false;
|
|
792
|
+
let exitCode = -1;
|
|
793
|
+
const exitMock = mock((code?: number) => {
|
|
794
|
+
exitCalled = true;
|
|
795
|
+
exitCode = code ?? -1;
|
|
796
|
+
});
|
|
797
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
798
|
+
|
|
799
|
+
simulateCancel = true;
|
|
800
|
+
|
|
801
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
802
|
+
|
|
803
|
+
expect(exitCalled).toBe(true);
|
|
804
|
+
expect(exitCode).toBe(0);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ------------------------------------------------------------------
|
|
809
|
+
// Global options (--json, --quiet)
|
|
810
|
+
// ------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
describe("global options", () => {
|
|
813
|
+
it("suppresses output in quiet mode", async () => {
|
|
814
|
+
responses = {
|
|
815
|
+
password: "valid-token",
|
|
816
|
+
text: "test-account",
|
|
817
|
+
multiselect: [],
|
|
818
|
+
confirm: false,
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
await runInitCommand({}, { json: false, quiet: true }, false);
|
|
822
|
+
|
|
823
|
+
// In quiet mode, the intro/outro patterns are still called but
|
|
824
|
+
// our formatSuccess/formatter functions respect quiet mode.
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
});
|