@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,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hoox init` — interactive setup wizard for Hoox Workspace.
|
|
3
|
+
*
|
|
4
|
+
* Interactive flow:
|
|
5
|
+
* 1. Collect & validate Cloudflare API token
|
|
6
|
+
* 2. Collect account ID
|
|
7
|
+
* 3. Collect secret store ID
|
|
8
|
+
* 4. Select integrations (exchanges, wallet, email, telegram)
|
|
9
|
+
* 5. Collect per-integration secrets
|
|
10
|
+
* 6. Write wrangler.jsonc
|
|
11
|
+
* 7. Create .dev.vars templates
|
|
12
|
+
*
|
|
13
|
+
* Non-interactive mode (--token, --account, --secret-store, --prefix):
|
|
14
|
+
* Skips all prompts and writes config with base workers only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as p from "@clack/prompts";
|
|
18
|
+
import { Command } from "commander";
|
|
19
|
+
import { CloudflareService } from "../../services/cloudflare/index.js";
|
|
20
|
+
import { formatSuccess, formatError } from "../../utils/formatters.js";
|
|
21
|
+
import { CLIError, ExitCode } from "../../utils/errors.js";
|
|
22
|
+
import { theme } from "../../utils/theme.js";
|
|
23
|
+
import { INTEGRATIONS, BASE_WORKERS, BASE_SECRETS } from "./types.js";
|
|
24
|
+
import type { InitOptions, WorkersJsonConfig, WorkerConfig } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Try to read an existing wrangler.jsonc from the current directory
|
|
32
|
+
* and extract the account ID (used as default value in prompt).
|
|
33
|
+
*/
|
|
34
|
+
async function getExistingAccountId(): Promise<string | undefined> {
|
|
35
|
+
try {
|
|
36
|
+
const file = Bun.file("wrangler.jsonc");
|
|
37
|
+
if (!(await file.exists())) return undefined;
|
|
38
|
+
const raw = await file.text();
|
|
39
|
+
// Simple regex extract — avoids depending on jsonc-parser for just this
|
|
40
|
+
const match = raw.match(/"cloudflare_account_id"\s*:\s*"([^"]+)"/);
|
|
41
|
+
return match ? match[1] : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a Cloudflare API token by calling `wrangler whoami`.
|
|
49
|
+
* Returns an error string on failure, undefined on success.
|
|
50
|
+
*/
|
|
51
|
+
async function validateApiToken(
|
|
52
|
+
cf: CloudflareService,
|
|
53
|
+
token: string
|
|
54
|
+
): Promise<string | undefined> {
|
|
55
|
+
// CloudflareService uses wrangler which reads CLOUDFLARE_API_TOKEN from env.
|
|
56
|
+
// We need to pass the token. Temporarily set the env var for the whoami call.
|
|
57
|
+
const prev = process.env.CLOUDFLARE_API_TOKEN;
|
|
58
|
+
process.env.CLOUDFLARE_API_TOKEN = token;
|
|
59
|
+
try {
|
|
60
|
+
const result = await cf.whoami();
|
|
61
|
+
if (result.ok) return undefined;
|
|
62
|
+
return `Authentication failed: ${result.error}`;
|
|
63
|
+
} finally {
|
|
64
|
+
if (prev) {
|
|
65
|
+
process.env.CLOUDFLARE_API_TOKEN = prev;
|
|
66
|
+
} else {
|
|
67
|
+
delete process.env.CLOUDFLARE_API_TOKEN;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the full wrangler.jsonc config object from collected values.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Extended worker config used internally during build to carry collected
|
|
77
|
+
* secret values forward to .dev.vars generation.
|
|
78
|
+
*/
|
|
79
|
+
interface InternalWorkerConfig extends WorkerConfig {
|
|
80
|
+
_collectedSecrets?: Record<string, string>;
|
|
81
|
+
_collectedBaseSecrets?: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildConfig(
|
|
85
|
+
globalToken: string,
|
|
86
|
+
globalAccount: string,
|
|
87
|
+
globalSecretStore: string,
|
|
88
|
+
globalPrefix: string,
|
|
89
|
+
selectedIntegrations: string[],
|
|
90
|
+
integrationSecrets: Record<string, Record<string, string>>,
|
|
91
|
+
baseSecrets: Record<string, Record<string, string>>
|
|
92
|
+
): WorkersJsonConfig {
|
|
93
|
+
const workers: Record<string, InternalWorkerConfig> = {};
|
|
94
|
+
|
|
95
|
+
// Base workers (always enabled)
|
|
96
|
+
for (const [name, baseCfg] of Object.entries(BASE_WORKERS)) {
|
|
97
|
+
const secrets = BASE_SECRETS[name] ? [...BASE_SECRETS[name]] : [];
|
|
98
|
+
workers[name] = {
|
|
99
|
+
enabled: true,
|
|
100
|
+
path: baseCfg.path,
|
|
101
|
+
vars: { ...baseCfg.vars },
|
|
102
|
+
secrets,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Integration workers — merge secrets per worker
|
|
107
|
+
for (const key of selectedIntegrations) {
|
|
108
|
+
const integration = INTEGRATIONS.find((i) => i.key === key);
|
|
109
|
+
if (!integration) continue;
|
|
110
|
+
|
|
111
|
+
const workerName = integration.workerName;
|
|
112
|
+
if (!workers[workerName]) {
|
|
113
|
+
workers[workerName] = {
|
|
114
|
+
enabled: true,
|
|
115
|
+
path: `workers/${workerName}`,
|
|
116
|
+
vars: {},
|
|
117
|
+
secrets: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add integration-specific vars
|
|
122
|
+
if (integration.vars) {
|
|
123
|
+
Object.assign(workers[workerName].vars, integration.vars);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Add integration-specific secrets
|
|
127
|
+
const collected = integrationSecrets[key] ?? {};
|
|
128
|
+
for (const secretName of Object.keys(integration.secrets)) {
|
|
129
|
+
if (!workers[workerName].secrets.includes(secretName)) {
|
|
130
|
+
workers[workerName].secrets.push(secretName);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Store collected values for .dev.vars
|
|
134
|
+
if (!workers[workerName]._collectedSecrets) {
|
|
135
|
+
workers[workerName]._collectedSecrets = {};
|
|
136
|
+
}
|
|
137
|
+
Object.assign(workers[workerName]._collectedSecrets!, collected);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Merge base secrets that are collected (per-worker map)
|
|
141
|
+
for (const [wName, secrets] of Object.entries(baseSecrets)) {
|
|
142
|
+
if (workers[wName]) {
|
|
143
|
+
workers[wName]._collectedBaseSecrets = secrets;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
global: {
|
|
149
|
+
cloudflare_api_token: globalToken,
|
|
150
|
+
cloudflare_account_id: globalAccount,
|
|
151
|
+
cloudflare_secret_store_id: globalSecretStore,
|
|
152
|
+
subdomain_prefix: globalPrefix,
|
|
153
|
+
},
|
|
154
|
+
workers: workers as Record<string, WorkerConfig>,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write wrangler.jsonc using JSON-style formatting.
|
|
160
|
+
* Uses JSON.stringify (not jsonc-parser for the main structure,
|
|
161
|
+
* but with a JSONC header comment).
|
|
162
|
+
*/
|
|
163
|
+
async function writeWorkersJsonc(
|
|
164
|
+
config: WorkersJsonConfig,
|
|
165
|
+
opts?: { json?: boolean; quiet?: boolean }
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const { workers, ...restGlobal } = config;
|
|
168
|
+
|
|
169
|
+
// Strip internal fields before serializing
|
|
170
|
+
const cleanWorkers: Record<string, WorkerConfig> = {};
|
|
171
|
+
for (const [name, worker] of Object.entries(workers)) {
|
|
172
|
+
const w = worker as WorkerConfig & {
|
|
173
|
+
_collectedSecrets?: Record<string, string>;
|
|
174
|
+
_collectedBaseSecrets?: Record<string, string>;
|
|
175
|
+
};
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
177
|
+
const { _collectedSecrets, _collectedBaseSecrets, ...clean } = w;
|
|
178
|
+
cleanWorkers[name] = clean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const out = {
|
|
182
|
+
...restGlobal,
|
|
183
|
+
workers: cleanWorkers,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Use jsonc-parser's format to get nice JSONC output
|
|
187
|
+
const { format, applyEdits } = await import("jsonc-parser");
|
|
188
|
+
|
|
189
|
+
const raw = JSON.stringify(out, null, 2);
|
|
190
|
+
const formattingOpts = {
|
|
191
|
+
insertSpaces: true,
|
|
192
|
+
tabSize: 2,
|
|
193
|
+
eol: "\n",
|
|
194
|
+
};
|
|
195
|
+
const edits = format(raw, undefined, formattingOpts);
|
|
196
|
+
const formatted = applyEdits(raw, edits);
|
|
197
|
+
|
|
198
|
+
const header =
|
|
199
|
+
"// Hoox Workspace Configuration\n" +
|
|
200
|
+
"// Generated by `hoox init`. Edit manually or re-run the wizard.\n" +
|
|
201
|
+
"// Secrets are referenced by name — actual values are stored in Cloudflare.\n\n";
|
|
202
|
+
|
|
203
|
+
await Bun.write("wrangler.jsonc", header + formatted);
|
|
204
|
+
|
|
205
|
+
if (!opts?.quiet) {
|
|
206
|
+
formatSuccess("wrangler.jsonc written", opts);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create .dev.vars template files for each worker with collected secrets.
|
|
212
|
+
*/
|
|
213
|
+
async function createDevVars(
|
|
214
|
+
config: WorkersJsonConfig,
|
|
215
|
+
integrationSecrets: Record<string, Record<string, string>>,
|
|
216
|
+
baseSecrets: Record<string, Record<string, string>>,
|
|
217
|
+
opts?: { json?: boolean; quiet?: boolean }
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
for (const [workerName, worker] of Object.entries(config.workers)) {
|
|
220
|
+
const lines: string[] = [];
|
|
221
|
+
lines.push(`# .dev.vars — local secrets for ${workerName}`);
|
|
222
|
+
lines.push(`# Generated by \`hoox init\`. NEVER commit this file.`);
|
|
223
|
+
lines.push("");
|
|
224
|
+
|
|
225
|
+
// Integration secrets for this worker
|
|
226
|
+
for (const integration of INTEGRATIONS) {
|
|
227
|
+
if (integration.workerName === workerName) {
|
|
228
|
+
const collected = integrationSecrets[integration.key];
|
|
229
|
+
if (collected) {
|
|
230
|
+
for (const [key, value] of Object.entries(collected)) {
|
|
231
|
+
lines.push(`${key}=${value}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Base secrets for this worker (from direct baseSecrets map)
|
|
238
|
+
const workerBaseSecrets = baseSecrets[workerName];
|
|
239
|
+
if (workerBaseSecrets) {
|
|
240
|
+
for (const [key, value] of Object.entries(workerBaseSecrets)) {
|
|
241
|
+
lines.push(`${key}=${value}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Also write secrets from BASE_SECRETS config that the user provided values for
|
|
246
|
+
const collectedBase = (
|
|
247
|
+
worker as unknown as { _collectedBaseSecrets?: Record<string, string> }
|
|
248
|
+
)._collectedBaseSecrets;
|
|
249
|
+
if (collectedBase) {
|
|
250
|
+
for (const [key, value] of Object.entries(collectedBase)) {
|
|
251
|
+
lines.push(`${key}=${value}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (lines.length <= 3) continue; // No secrets for this worker
|
|
256
|
+
|
|
257
|
+
const content = lines.join("\n") + "\n";
|
|
258
|
+
const filePath = `${worker.path}/.dev.vars`;
|
|
259
|
+
const dir = `${worker.path}`;
|
|
260
|
+
|
|
261
|
+
// Ensure directory exists
|
|
262
|
+
try {
|
|
263
|
+
await Bun.write(filePath, content);
|
|
264
|
+
if (!opts?.quiet) {
|
|
265
|
+
formatSuccess(`Created ${filePath}`, opts);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// If directory doesn't exist, create and retry
|
|
269
|
+
const { mkdir } = await import("node:fs/promises");
|
|
270
|
+
await mkdir(dir, { recursive: true });
|
|
271
|
+
await Bun.write(filePath, content);
|
|
272
|
+
if (!opts?.quiet) {
|
|
273
|
+
formatSuccess(`Created ${filePath}`, opts);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prompt for per-integration secrets. Returns a map of integration key →
|
|
281
|
+
* collected secret values.
|
|
282
|
+
*/
|
|
283
|
+
async function collectIntegrationSecrets(
|
|
284
|
+
selectedIntegrations: string[]
|
|
285
|
+
): Promise<Record<string, Record<string, string>>> {
|
|
286
|
+
const result: Record<string, Record<string, string>> = {};
|
|
287
|
+
|
|
288
|
+
for (const key of selectedIntegrations) {
|
|
289
|
+
const integration = INTEGRATIONS.find((i) => i.key === key);
|
|
290
|
+
if (!integration || Object.keys(integration.secrets).length === 0) continue;
|
|
291
|
+
|
|
292
|
+
const secretEntries = Object.entries(integration.secrets);
|
|
293
|
+
const groupFields: Record<string, () => Promise<string | symbol>> = {};
|
|
294
|
+
|
|
295
|
+
for (const [secretName] of secretEntries) {
|
|
296
|
+
groupFields[secretName] = () =>
|
|
297
|
+
p.password({
|
|
298
|
+
message: `${integration.secrets[secretName]}:`,
|
|
299
|
+
validate(value) {
|
|
300
|
+
if (!value) return "This secret is required";
|
|
301
|
+
return;
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const collected = await p.group(groupFields, {
|
|
307
|
+
onCancel: () => {
|
|
308
|
+
p.cancel("Setup cancelled.");
|
|
309
|
+
process.exit(0);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Convert Symbol values (shouldn't happen due to onCancel) to empty strings
|
|
314
|
+
result[key] = {};
|
|
315
|
+
for (const [secretName] of secretEntries) {
|
|
316
|
+
const val = collected[secretName];
|
|
317
|
+
result[key][secretName] = typeof val === "string" ? val : "";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Main command registration
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
export function registerInitCommand(program: Command): void {
|
|
329
|
+
program
|
|
330
|
+
.command("init")
|
|
331
|
+
.summary("Interactive setup wizard for Hoox Workspace")
|
|
332
|
+
.description(
|
|
333
|
+
`Initialize a new Hoox workspace with interactive prompts.
|
|
334
|
+
|
|
335
|
+
This wizard helps you set up:
|
|
336
|
+
- Cloudflare API token and account
|
|
337
|
+
- Subdomain prefix for worker URLs
|
|
338
|
+
- Secret store configuration
|
|
339
|
+
- Initial worker configuration
|
|
340
|
+
|
|
341
|
+
INTERACTIVE MODE:
|
|
342
|
+
Run without flags to use the interactive wizard.
|
|
343
|
+
|
|
344
|
+
NON-INTERACTIVE MODE:
|
|
345
|
+
Provide all required options to skip prompts.
|
|
346
|
+
|
|
347
|
+
OPTIONS:
|
|
348
|
+
--token <token> Cloudflare API token
|
|
349
|
+
--account <id> Cloudflare Account ID
|
|
350
|
+
--secret-store <id> Secret Store ID
|
|
351
|
+
--prefix <prefix> Subdomain prefix (default: cryptolinx)
|
|
352
|
+
--accept-risk Skip risk acknowledgment
|
|
353
|
+
|
|
354
|
+
EXAMPLES:
|
|
355
|
+
hoox init Interactive wizard
|
|
356
|
+
hoox init --token cfut_xxx --account xxx Non-interactive`
|
|
357
|
+
)
|
|
358
|
+
.option("--token <token>", "Cloudflare API token (non-interactive)")
|
|
359
|
+
.option("--account <id>", "Cloudflare Account ID (non-interactive)")
|
|
360
|
+
.option("--secret-store <id>", "Secret Store ID (non-interactive)")
|
|
361
|
+
.option(
|
|
362
|
+
"--prefix <prefix>",
|
|
363
|
+
"Subdomain prefix (non-interactive, default: cryptolinx)"
|
|
364
|
+
)
|
|
365
|
+
.option("--accept-risk", "Skip the risk acknowledgment confirmation")
|
|
366
|
+
.action(async (options: InitOptions) => {
|
|
367
|
+
const globalOpts = program.opts() as { json?: boolean; quiet?: boolean };
|
|
368
|
+
const isNonInteractive = Boolean(options.token && options.account);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await runInitCommand(options, globalOpts, isNonInteractive);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
374
|
+
p.log.error(message);
|
|
375
|
+
formatError(new CLIError(message, ExitCode.ERROR), globalOpts);
|
|
376
|
+
process.exit(ExitCode.ERROR);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Core logic extracted for testability.
|
|
383
|
+
*/
|
|
384
|
+
export async function runInitCommand(
|
|
385
|
+
options: InitOptions,
|
|
386
|
+
globalOpts: { json?: boolean; quiet?: boolean },
|
|
387
|
+
isNonInteractive: boolean
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
// ------------------------------------------------------------------
|
|
390
|
+
// Non-interactive mode: write config directly from flags
|
|
391
|
+
// ------------------------------------------------------------------
|
|
392
|
+
if (isNonInteractive) {
|
|
393
|
+
if (!globalOpts.quiet) {
|
|
394
|
+
p.intro("Hoox Setup Wizard");
|
|
395
|
+
p.note("Non-interactive mode — using provided flags.", "Mode");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const token = options.token!;
|
|
399
|
+
const account = options.account!;
|
|
400
|
+
const secretStore = options.secretStore ?? "";
|
|
401
|
+
const prefix = options.prefix ?? "cryptolinx";
|
|
402
|
+
|
|
403
|
+
// Validate token
|
|
404
|
+
const cf = new CloudflareService();
|
|
405
|
+
const error = await validateApiToken(cf, token);
|
|
406
|
+
if (error) {
|
|
407
|
+
formatError(new CLIError(error, ExitCode.ERROR), globalOpts);
|
|
408
|
+
process.exit(ExitCode.ERROR);
|
|
409
|
+
}
|
|
410
|
+
if (!globalOpts.quiet) {
|
|
411
|
+
formatSuccess("Cloudflare API token validated", globalOpts);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const config = buildConfig(
|
|
415
|
+
token,
|
|
416
|
+
account,
|
|
417
|
+
secretStore,
|
|
418
|
+
prefix,
|
|
419
|
+
[], // no integrations in non-interactive mode
|
|
420
|
+
{},
|
|
421
|
+
{}
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await writeWorkersJsonc(config, globalOpts);
|
|
425
|
+
await createDevVars(config, {}, {}, globalOpts);
|
|
426
|
+
|
|
427
|
+
if (!globalOpts.quiet) {
|
|
428
|
+
p.outro("Setup complete! Run hoox check setup to verify.");
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ------------------------------------------------------------------
|
|
434
|
+
// Interactive mode
|
|
435
|
+
// ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
p.intro(theme.heading("Hoox Setup Wizard"));
|
|
438
|
+
|
|
439
|
+
const skipRiskWarning = options.acceptRisk || isNonInteractive;
|
|
440
|
+
if (!skipRiskWarning) {
|
|
441
|
+
const accepted = await p.confirm({
|
|
442
|
+
message:
|
|
443
|
+
"Hoox connects to live trading exchanges and can execute real trades with real money. " +
|
|
444
|
+
"By continuing, you acknowledge that you are solely responsible for all trading activity " +
|
|
445
|
+
"and accept the risk of financial loss. Do you accept these terms?",
|
|
446
|
+
initialValue: false,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (p.isCancel(accepted)) {
|
|
450
|
+
p.cancel("Setup cancelled.");
|
|
451
|
+
process.exit(0);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!accepted) {
|
|
455
|
+
p.outro("Setup cancelled. See DISCLAIMER.md for full terms.");
|
|
456
|
+
process.exit(0);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Step 1: Cloudflare API token with validation loop
|
|
461
|
+
const cf = new CloudflareService();
|
|
462
|
+
let apiToken: string | symbol = "";
|
|
463
|
+
let tokenValid = false;
|
|
464
|
+
|
|
465
|
+
while (!tokenValid) {
|
|
466
|
+
apiToken = await p.password({
|
|
467
|
+
message: "Cloudflare API token:",
|
|
468
|
+
validate(value) {
|
|
469
|
+
if (!value) return "API token is required";
|
|
470
|
+
return;
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
if (p.isCancel(apiToken)) {
|
|
475
|
+
p.cancel("Setup cancelled.");
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
p.log.step("Validating Cloudflare API token...");
|
|
480
|
+
const tokenError = await validateApiToken(cf, apiToken as string);
|
|
481
|
+
if (tokenError) {
|
|
482
|
+
p.log.error(tokenError);
|
|
483
|
+
p.log.warn("Please check your token and try again.");
|
|
484
|
+
} else {
|
|
485
|
+
tokenValid = true;
|
|
486
|
+
formatSuccess("Cloudflare API token validated", globalOpts);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Step 2: Account ID
|
|
491
|
+
const defaultAccountId = await getExistingAccountId();
|
|
492
|
+
const accountResult = await p.text({
|
|
493
|
+
message: "Cloudflare Account ID:",
|
|
494
|
+
placeholder: "abc123...",
|
|
495
|
+
defaultValue: defaultAccountId ?? "",
|
|
496
|
+
validate(value) {
|
|
497
|
+
if (!value) return "Account ID is required";
|
|
498
|
+
return;
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
if (p.isCancel(accountResult)) {
|
|
502
|
+
p.cancel("Setup cancelled.");
|
|
503
|
+
process.exit(0);
|
|
504
|
+
}
|
|
505
|
+
const accountId: string = accountResult;
|
|
506
|
+
|
|
507
|
+
// Step 3: Secret Store ID
|
|
508
|
+
const secretStoreResult = await p.text({
|
|
509
|
+
message: "Cloudflare Secret Store ID:",
|
|
510
|
+
placeholder: "optional",
|
|
511
|
+
defaultValue: "",
|
|
512
|
+
});
|
|
513
|
+
if (p.isCancel(secretStoreResult)) {
|
|
514
|
+
p.cancel("Setup cancelled.");
|
|
515
|
+
process.exit(0);
|
|
516
|
+
}
|
|
517
|
+
const secretStoreId: string = secretStoreResult;
|
|
518
|
+
|
|
519
|
+
// Step 4: Subdomain prefix
|
|
520
|
+
const prefixResult = await p.text({
|
|
521
|
+
message: "Subdomain prefix:",
|
|
522
|
+
placeholder: "cryptolinx",
|
|
523
|
+
defaultValue: "cryptolinx",
|
|
524
|
+
validate(value) {
|
|
525
|
+
if (!value) return "Subdomain prefix is required";
|
|
526
|
+
return;
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
if (p.isCancel(prefixResult)) {
|
|
530
|
+
p.cancel("Setup cancelled.");
|
|
531
|
+
process.exit(0);
|
|
532
|
+
}
|
|
533
|
+
const prefix: string = prefixResult;
|
|
534
|
+
|
|
535
|
+
// Step 5: Select integrations
|
|
536
|
+
const integrationOptions = INTEGRATIONS.map((i) => ({
|
|
537
|
+
value: i.key,
|
|
538
|
+
label: i.label,
|
|
539
|
+
hint: i.workerName,
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
const selectedIntegrations = await p.multiselect({
|
|
543
|
+
message: "Select integrations to enable:",
|
|
544
|
+
options: integrationOptions,
|
|
545
|
+
required: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (p.isCancel(selectedIntegrations)) {
|
|
549
|
+
p.cancel("Setup cancelled.");
|
|
550
|
+
process.exit(0);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const selected: string[] = Array.isArray(selectedIntegrations)
|
|
554
|
+
? selectedIntegrations
|
|
555
|
+
: [];
|
|
556
|
+
|
|
557
|
+
// Step 6: Collect per-integration secrets
|
|
558
|
+
p.log.step("Collecting integration secrets...");
|
|
559
|
+
const integrationSecrets = await collectIntegrationSecrets(selected);
|
|
560
|
+
|
|
561
|
+
// Step 7: Collect base secrets for always-enabled workers
|
|
562
|
+
const baseSecrets: Record<string, Record<string, string>> = {};
|
|
563
|
+
|
|
564
|
+
const collectBaseSecrets =
|
|
565
|
+
selected.length > 0 ||
|
|
566
|
+
(await p.confirm({
|
|
567
|
+
message: "Configure base worker secrets?",
|
|
568
|
+
initialValue: true,
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
if (collectBaseSecrets) {
|
|
572
|
+
for (const [workerName, secretNames] of Object.entries(BASE_SECRETS)) {
|
|
573
|
+
if (secretNames.length === 0) continue;
|
|
574
|
+
|
|
575
|
+
const groupFields: Record<string, () => Promise<string | symbol>> = {};
|
|
576
|
+
for (const secretName of secretNames) {
|
|
577
|
+
groupFields[secretName] = () =>
|
|
578
|
+
p.password({
|
|
579
|
+
message: `${workerName} — ${secretName}:`,
|
|
580
|
+
validate(value) {
|
|
581
|
+
if (!value) return `Required for ${workerName}`;
|
|
582
|
+
return;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
p.log.step(`Secrets for ${workerName}...`);
|
|
588
|
+
const collected = await p.group(groupFields, {
|
|
589
|
+
onCancel: () => {
|
|
590
|
+
p.cancel("Setup cancelled.");
|
|
591
|
+
process.exit(0);
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
baseSecrets[workerName] = {};
|
|
596
|
+
for (const secretName of secretNames) {
|
|
597
|
+
const val = collected[secretName];
|
|
598
|
+
if (typeof val === "string") {
|
|
599
|
+
baseSecrets[workerName][secretName] = val;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Step 8: Build and write config
|
|
606
|
+
p.log.step("Writing configuration...");
|
|
607
|
+
|
|
608
|
+
const config = buildConfig(
|
|
609
|
+
apiToken as string,
|
|
610
|
+
accountId,
|
|
611
|
+
secretStoreId,
|
|
612
|
+
prefix,
|
|
613
|
+
selected,
|
|
614
|
+
integrationSecrets,
|
|
615
|
+
baseSecrets
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
await writeWorkersJsonc(config, globalOpts);
|
|
619
|
+
await createDevVars(config, integrationSecrets, baseSecrets, globalOpts);
|
|
620
|
+
|
|
621
|
+
// Done
|
|
622
|
+
p.outro(
|
|
623
|
+
theme.success("Setup complete! Run ") +
|
|
624
|
+
theme.bold("hoox check setup") +
|
|
625
|
+
theme.success(" to verify.")
|
|
626
|
+
);
|
|
627
|
+
}
|