@jango-blockchained/hoox-cli 0.4.1 → 0.5.0
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/package.json +1 -1
- package/src/commands/dashboard/dashboard-command.ts +3 -2
- package/src/commands/deploy/deploy-command.ts +3 -2
- package/src/commands/init/__tests__/cli-provisioner.test.ts +17 -0
- package/src/commands/init/cli-provisioner.ts +71 -0
- package/src/commands/init/init-command.test.ts +109 -60
- package/src/commands/init/init-command.ts +330 -364
- package/src/commands/init/types.ts +6 -168
- package/src/commands/logs/index.ts +1 -0
- package/src/commands/logs/logs-command.test.ts +294 -0
- package/src/commands/logs/logs-command.ts +452 -0
- package/src/commands/update/update-command.ts +143 -26
- package/src/index.ts +20 -0
- package/src/ui/banner.ts +117 -28
- package/src/ui/menu.ts +15 -3
|
@@ -1,42 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `hoox init` — interactive setup wizard for Hoox Workspace.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
4
|
+
* Uses the shared WizardEngine from @jango-blockchained/hoox-shared.
|
|
12
5
|
*
|
|
6
|
+
* Interactive flow uses @clack/prompts for the UI layer.
|
|
13
7
|
* Non-interactive mode (--token, --account, --secret-store, --prefix):
|
|
14
8
|
* Skips all prompts and writes config with base workers only.
|
|
15
9
|
*/
|
|
16
10
|
|
|
17
11
|
import * as p from "@clack/prompts";
|
|
18
12
|
import { Command } from "commander";
|
|
13
|
+
import {
|
|
14
|
+
WizardEngine,
|
|
15
|
+
serializeState,
|
|
16
|
+
deserializeState,
|
|
17
|
+
WIZARD_STATE_PATH,
|
|
18
|
+
} from "@jango-blockchained/hoox-shared";
|
|
19
|
+
import type { WorkersJsonConfig } from "@jango-blockchained/hoox-shared";
|
|
19
20
|
import { CloudflareService } from "../../services/cloudflare/index.js";
|
|
20
21
|
import { formatSuccess, formatError } from "../../utils/formatters.js";
|
|
21
22
|
import { CLIError, ExitCode } from "../../utils/errors.js";
|
|
22
23
|
import { theme } from "../../utils/theme.js";
|
|
23
|
-
import {
|
|
24
|
-
import type { InitOptions
|
|
24
|
+
import { CLIProvisioner } from "./cli-provisioner.js";
|
|
25
|
+
import type { InitOptions } from "./types.js";
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Helpers
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
29
30
|
|
|
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
31
|
async function getExistingAccountId(): Promise<string | undefined> {
|
|
35
32
|
try {
|
|
36
33
|
const file = Bun.file("wrangler.jsonc");
|
|
37
34
|
if (!(await file.exists())) return undefined;
|
|
38
35
|
const raw = await file.text();
|
|
39
|
-
// Simple regex extract — avoids depending on jsonc-parser for just this
|
|
40
36
|
const match = raw.match(/"cloudflare_account_id"\s*:\s*"([^"]+)"/);
|
|
41
37
|
return match ? match[1] : undefined;
|
|
42
38
|
} catch {
|
|
@@ -44,16 +40,10 @@ async function getExistingAccountId(): Promise<string | undefined> {
|
|
|
44
40
|
}
|
|
45
41
|
}
|
|
46
42
|
|
|
47
|
-
/**
|
|
48
|
-
* Validate a Cloudflare API token by calling `wrangler whoami`.
|
|
49
|
-
* Returns an error string on failure, undefined on success.
|
|
50
|
-
*/
|
|
51
43
|
async function validateApiToken(
|
|
52
44
|
cf: CloudflareService,
|
|
53
45
|
token: string
|
|
54
46
|
): 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
47
|
const prev = process.env.CLOUDFLARE_API_TOKEN;
|
|
58
48
|
process.env.CLOUDFLARE_API_TOKEN = token;
|
|
59
49
|
try {
|
|
@@ -69,124 +59,13 @@ async function validateApiToken(
|
|
|
69
59
|
}
|
|
70
60
|
}
|
|
71
61
|
|
|
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
62
|
async function writeWorkersJsonc(
|
|
164
63
|
config: WorkersJsonConfig,
|
|
165
64
|
opts?: { json?: boolean; quiet?: boolean }
|
|
166
65
|
): 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
66
|
const { format, applyEdits } = await import("jsonc-parser");
|
|
188
67
|
|
|
189
|
-
const raw = JSON.stringify(
|
|
68
|
+
const raw = JSON.stringify(config, null, 2);
|
|
190
69
|
const formattingOpts = {
|
|
191
70
|
insertSpaces: true,
|
|
192
71
|
tabSize: 2,
|
|
@@ -207,13 +86,9 @@ async function writeWorkersJsonc(
|
|
|
207
86
|
}
|
|
208
87
|
}
|
|
209
88
|
|
|
210
|
-
/**
|
|
211
|
-
* Create .dev.vars template files for each worker with collected secrets.
|
|
212
|
-
*/
|
|
213
89
|
async function createDevVars(
|
|
214
90
|
config: WorkersJsonConfig,
|
|
215
|
-
|
|
216
|
-
baseSecrets: Record<string, Record<string, string>>,
|
|
91
|
+
secrets: Record<string, Record<string, string>>,
|
|
217
92
|
opts?: { json?: boolean; quiet?: boolean }
|
|
218
93
|
): Promise<void> {
|
|
219
94
|
for (const [workerName, worker] of Object.entries(config.workers)) {
|
|
@@ -222,50 +97,25 @@ async function createDevVars(
|
|
|
222
97
|
lines.push(`# Generated by \`hoox init\`. NEVER commit this file.`);
|
|
223
98
|
lines.push("");
|
|
224
99
|
|
|
225
|
-
//
|
|
226
|
-
for (const
|
|
227
|
-
|
|
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)) {
|
|
100
|
+
// Add secrets from the collected data
|
|
101
|
+
for (const [, integrationSecrets] of Object.entries(secrets)) {
|
|
102
|
+
for (const [key, value] of Object.entries(integrationSecrets)) {
|
|
241
103
|
lines.push(`${key}=${value}`);
|
|
242
104
|
}
|
|
243
105
|
}
|
|
244
106
|
|
|
245
|
-
|
|
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
|
|
107
|
+
if (lines.length <= 3) continue;
|
|
256
108
|
|
|
257
109
|
const content = lines.join("\n") + "\n";
|
|
258
110
|
const filePath = `${worker.path}/.dev.vars`;
|
|
259
111
|
const dir = `${worker.path}`;
|
|
260
112
|
|
|
261
|
-
// Ensure directory exists
|
|
262
113
|
try {
|
|
263
114
|
await Bun.write(filePath, content);
|
|
264
115
|
if (!opts?.quiet) {
|
|
265
116
|
formatSuccess(`Created ${filePath}`, opts);
|
|
266
117
|
}
|
|
267
118
|
} catch {
|
|
268
|
-
// If directory doesn't exist, create and retry
|
|
269
119
|
const { mkdir } = await import("node:fs/promises");
|
|
270
120
|
await mkdir(dir, { recursive: true });
|
|
271
121
|
await Bun.write(filePath, content);
|
|
@@ -276,49 +126,33 @@ async function createDevVars(
|
|
|
276
126
|
}
|
|
277
127
|
}
|
|
278
128
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
}
|
|
129
|
+
async function saveState(engine: WizardEngine): Promise<void> {
|
|
130
|
+
const state = engine.getState();
|
|
131
|
+
const json = serializeState(state);
|
|
132
|
+
await Bun.write(WIZARD_STATE_PATH, json);
|
|
133
|
+
}
|
|
305
134
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
135
|
+
async function loadSavedState(): Promise<WizardEngine | null> {
|
|
136
|
+
try {
|
|
137
|
+
const file = Bun.file(WIZARD_STATE_PATH);
|
|
138
|
+
if (!(await file.exists())) return null;
|
|
139
|
+
const json = await file.text();
|
|
140
|
+
const state = deserializeState(json);
|
|
141
|
+
return new WizardEngine(state);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
312
146
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
147
|
+
async function cleanupState(): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
const file = Bun.file(WIZARD_STATE_PATH);
|
|
150
|
+
if (await file.exists()) {
|
|
151
|
+
await Bun.write(WIZARD_STATE_PATH, "");
|
|
318
152
|
}
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore
|
|
319
155
|
}
|
|
320
|
-
|
|
321
|
-
return result;
|
|
322
156
|
}
|
|
323
157
|
|
|
324
158
|
// ---------------------------------------------------------------------------
|
|
@@ -336,6 +170,8 @@ This wizard helps you set up:
|
|
|
336
170
|
- Cloudflare API token and account
|
|
337
171
|
- Subdomain prefix for worker URLs
|
|
338
172
|
- Secret store configuration
|
|
173
|
+
- Worker selection with preset templates
|
|
174
|
+
- Infrastructure provisioning (D1, KV)
|
|
339
175
|
- Initial worker configuration
|
|
340
176
|
|
|
341
177
|
INTERACTIVE MODE:
|
|
@@ -349,6 +185,8 @@ OPTIONS:
|
|
|
349
185
|
--account <id> Cloudflare Account ID
|
|
350
186
|
--secret-store <id> Secret Store ID
|
|
351
187
|
--prefix <prefix> Subdomain prefix (default: cryptolinx)
|
|
188
|
+
--preset <name> Worker preset (minimal, standard, full)
|
|
189
|
+
--resume Resume from saved wizard state
|
|
352
190
|
--accept-risk Skip risk acknowledgment
|
|
353
191
|
|
|
354
192
|
EXAMPLES:
|
|
@@ -362,6 +200,8 @@ EXAMPLES:
|
|
|
362
200
|
"--prefix <prefix>",
|
|
363
201
|
"Subdomain prefix (non-interactive, default: cryptolinx)"
|
|
364
202
|
)
|
|
203
|
+
.option("--preset <name>", "Worker preset (minimal, standard, full)")
|
|
204
|
+
.option("--resume", "Resume from saved wizard state")
|
|
365
205
|
.option("--accept-risk", "Skip the risk acknowledgment confirmation")
|
|
366
206
|
.action(async (options: InitOptions) => {
|
|
367
207
|
const globalOpts = program.opts() as { json?: boolean; quiet?: boolean };
|
|
@@ -378,17 +218,16 @@ EXAMPLES:
|
|
|
378
218
|
});
|
|
379
219
|
}
|
|
380
220
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Core logic
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
384
225
|
export async function runInitCommand(
|
|
385
226
|
options: InitOptions,
|
|
386
227
|
globalOpts: { json?: boolean; quiet?: boolean },
|
|
387
228
|
isNonInteractive: boolean
|
|
388
229
|
): Promise<void> {
|
|
389
|
-
//
|
|
390
|
-
// Non-interactive mode: write config directly from flags
|
|
391
|
-
// ------------------------------------------------------------------
|
|
230
|
+
// ── Non-interactive mode ───────────────────────────────────────────────
|
|
392
231
|
if (isNonInteractive) {
|
|
393
232
|
if (!globalOpts.quiet) {
|
|
394
233
|
p.intro("Hoox Setup Wizard");
|
|
@@ -411,18 +250,29 @@ export async function runInitCommand(
|
|
|
411
250
|
formatSuccess("Cloudflare API token validated", globalOpts);
|
|
412
251
|
}
|
|
413
252
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
);
|
|
253
|
+
// Use engine to build config
|
|
254
|
+
const engine = new WizardEngine();
|
|
255
|
+
engine.execute({ checksPassed: true });
|
|
256
|
+
engine.execute({
|
|
257
|
+
apiToken: token,
|
|
258
|
+
accountId: account,
|
|
259
|
+
secretStoreId: secretStore,
|
|
260
|
+
subdomain: prefix,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Apply preset if provided
|
|
264
|
+
if (
|
|
265
|
+
options.preset &&
|
|
266
|
+
["minimal", "standard", "full"].includes(options.preset)
|
|
267
|
+
) {
|
|
268
|
+
engine.execute({ preset: options.preset });
|
|
269
|
+
} else {
|
|
270
|
+
engine.execute({ preset: "minimal" as const });
|
|
271
|
+
}
|
|
423
272
|
|
|
273
|
+
const config = engine.buildConfig();
|
|
424
274
|
await writeWorkersJsonc(config, globalOpts);
|
|
425
|
-
await createDevVars(config, {},
|
|
275
|
+
await createDevVars(config, {}, globalOpts);
|
|
426
276
|
|
|
427
277
|
if (!globalOpts.quiet) {
|
|
428
278
|
p.outro("Setup complete! Run hoox check setup to verify.");
|
|
@@ -430,14 +280,36 @@ export async function runInitCommand(
|
|
|
430
280
|
return;
|
|
431
281
|
}
|
|
432
282
|
|
|
433
|
-
//
|
|
434
|
-
// Interactive mode
|
|
435
|
-
// ------------------------------------------------------------------
|
|
283
|
+
// ── Interactive mode ─────────────────────────────────────────────────
|
|
436
284
|
|
|
437
285
|
p.intro(theme.heading("Hoox Setup Wizard"));
|
|
438
286
|
|
|
439
|
-
|
|
440
|
-
|
|
287
|
+
// Try to resume from saved state
|
|
288
|
+
let engine: WizardEngine | null = null;
|
|
289
|
+
if (options.resume) {
|
|
290
|
+
engine = await loadSavedState();
|
|
291
|
+
if (engine) {
|
|
292
|
+
const resumeStep = engine.getCurrentStep().label;
|
|
293
|
+
const resumed = await p.confirm({
|
|
294
|
+
message: `Found saved wizard state. Resume from "${resumeStep}"?`,
|
|
295
|
+
initialValue: true,
|
|
296
|
+
});
|
|
297
|
+
if (p.isCancel(resumed)) {
|
|
298
|
+
p.cancel("Setup cancelled.");
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
if (!resumed) {
|
|
302
|
+
engine = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!engine) {
|
|
308
|
+
engine = new WizardEngine();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const skipRiskWarning = options.acceptRisk;
|
|
312
|
+
if (!skipRiskWarning && engine.getCurrentStep().id === "PREREQUISITES") {
|
|
441
313
|
const accepted = await p.confirm({
|
|
442
314
|
message:
|
|
443
315
|
"Hoox connects to live trading exchanges and can execute real trades with real money. " +
|
|
@@ -455,170 +327,264 @@ export async function runInitCommand(
|
|
|
455
327
|
p.outro("Setup cancelled. See DISCLAIMER.md for full terms.");
|
|
456
328
|
process.exit(0);
|
|
457
329
|
}
|
|
330
|
+
|
|
331
|
+
// Step 0: Prerequisites check (always passes — CLI is running)
|
|
332
|
+
engine.execute({ checksPassed: true });
|
|
333
|
+
await saveState(engine);
|
|
458
334
|
}
|
|
459
335
|
|
|
460
|
-
// Step 1: Cloudflare API token
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
336
|
+
// ── Step 1: Cloudflare API token ──────────────────────────────────────
|
|
337
|
+
if (engine.getCurrentStep().id === "CLOUDFLARE_CONFIG") {
|
|
338
|
+
const cf = new CloudflareService();
|
|
339
|
+
let apiToken: string | symbol = "";
|
|
340
|
+
let tokenValid = false;
|
|
341
|
+
|
|
342
|
+
while (!tokenValid) {
|
|
343
|
+
apiToken = await p.password({
|
|
344
|
+
message: "Cloudflare API token:",
|
|
345
|
+
validate(value) {
|
|
346
|
+
if (!value) return "API token is required";
|
|
347
|
+
return;
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (p.isCancel(apiToken)) {
|
|
352
|
+
p.cancel("Setup cancelled.");
|
|
353
|
+
process.exit(0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
p.log.step("Validating Cloudflare API token...");
|
|
357
|
+
const tokenError = await validateApiToken(cf, apiToken as string);
|
|
358
|
+
if (tokenError) {
|
|
359
|
+
p.log.error(tokenError);
|
|
360
|
+
p.log.warn("Please check your token and try again.");
|
|
361
|
+
} else {
|
|
362
|
+
tokenValid = true;
|
|
363
|
+
formatSuccess("Cloudflare API token validated", globalOpts);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
464
366
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
message: "Cloudflare
|
|
367
|
+
const defaultAccountId = await getExistingAccountId();
|
|
368
|
+
const accountResult = await p.text({
|
|
369
|
+
message: "Cloudflare Account ID:",
|
|
370
|
+
placeholder: "abc123...",
|
|
371
|
+
defaultValue: defaultAccountId ?? "",
|
|
468
372
|
validate(value) {
|
|
469
|
-
if (!value) return "
|
|
373
|
+
if (!value) return "Account ID is required";
|
|
374
|
+
if (!/^[a-f0-9]{32}$/i.test(value.trim())) {
|
|
375
|
+
return "Account ID should be a 32-character hex string";
|
|
376
|
+
}
|
|
470
377
|
return;
|
|
471
378
|
},
|
|
472
379
|
});
|
|
380
|
+
if (p.isCancel(accountResult)) {
|
|
381
|
+
p.cancel("Setup cancelled.");
|
|
382
|
+
process.exit(0);
|
|
383
|
+
}
|
|
473
384
|
|
|
474
|
-
|
|
385
|
+
const secretStoreResult = await p.text({
|
|
386
|
+
message: "Cloudflare Secret Store ID:",
|
|
387
|
+
placeholder: "optional",
|
|
388
|
+
defaultValue: "",
|
|
389
|
+
});
|
|
390
|
+
if (p.isCancel(secretStoreResult)) {
|
|
475
391
|
p.cancel("Setup cancelled.");
|
|
476
392
|
process.exit(0);
|
|
477
393
|
}
|
|
478
394
|
|
|
479
|
-
p.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
395
|
+
const prefixResult = await p.text({
|
|
396
|
+
message: "Subdomain prefix:",
|
|
397
|
+
placeholder: "cryptolinx",
|
|
398
|
+
defaultValue: "cryptolinx",
|
|
399
|
+
validate(value) {
|
|
400
|
+
if (!value) return "Subdomain prefix is required";
|
|
401
|
+
return;
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
if (p.isCancel(prefixResult)) {
|
|
405
|
+
p.cancel("Setup cancelled.");
|
|
406
|
+
process.exit(0);
|
|
487
407
|
}
|
|
488
|
-
}
|
|
489
408
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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);
|
|
409
|
+
engine.execute({
|
|
410
|
+
apiToken: apiToken as string,
|
|
411
|
+
accountId: accountResult,
|
|
412
|
+
secretStoreId: secretStoreResult,
|
|
413
|
+
subdomain: prefixResult,
|
|
414
|
+
});
|
|
415
|
+
await saveState(engine);
|
|
532
416
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
417
|
+
|
|
418
|
+
// ── Step 2: Worker selection with presets ──────────────────────────────
|
|
419
|
+
if (engine.getCurrentStep().id === "WORKER_SELECTION") {
|
|
420
|
+
const presetChoice = await p.select({
|
|
421
|
+
message: "Select a worker preset:",
|
|
422
|
+
options: [
|
|
423
|
+
{
|
|
424
|
+
value: "minimal",
|
|
425
|
+
label: "Minimal",
|
|
426
|
+
hint: "Gateway + D1 — webhook processing only",
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
value: "standard",
|
|
430
|
+
label: "Standard",
|
|
431
|
+
hint: "Trading + analytics + Telegram",
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
value: "full",
|
|
435
|
+
label: "Full",
|
|
436
|
+
hint: "All workers + AI + DeFi + email",
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
if (p.isCancel(presetChoice)) {
|
|
442
|
+
p.cancel("Setup cancelled.");
|
|
443
|
+
process.exit(0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
engine.execute({ preset: presetChoice as string });
|
|
447
|
+
await saveState(engine);
|
|
448
|
+
|
|
449
|
+
// Show selected workers
|
|
450
|
+
const state = engine.getState();
|
|
451
|
+
p.log.step(`Selected workers: ${state.selectedWorkers.join(", ")}`);
|
|
452
|
+
if (state.selectedIntegrations.length > 0) {
|
|
453
|
+
p.log.step(`Integrations: ${state.selectedIntegrations.join(", ")}`);
|
|
454
|
+
}
|
|
551
455
|
}
|
|
552
456
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
});
|
|
457
|
+
// ── Step 3: Provisioning ──────────────────────────────────────────────
|
|
458
|
+
if (engine.getCurrentStep().id === "PROVISIONING") {
|
|
459
|
+
const plan = engine.getProvisioningPlan();
|
|
460
|
+
const hasResources =
|
|
461
|
+
plan.d1Databases.length > 0 || plan.kvNamespaces.length > 0;
|
|
462
|
+
|
|
463
|
+
if (hasResources) {
|
|
464
|
+
const shouldProvision = await p.confirm({
|
|
465
|
+
message: `Provision Cloudflare resources? (${[
|
|
466
|
+
...plan.d1Databases.map((d) => `D1:${d}`),
|
|
467
|
+
...plan.kvNamespaces.map((k) => `KV:${k}`),
|
|
468
|
+
].join(", ")})`,
|
|
469
|
+
initialValue: true,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (p.isCancel(shouldProvision)) {
|
|
473
|
+
p.cancel("Setup cancelled.");
|
|
474
|
+
process.exit(0);
|
|
585
475
|
}
|
|
586
476
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
process.exit(0);
|
|
592
|
-
},
|
|
593
|
-
});
|
|
477
|
+
if (shouldProvision) {
|
|
478
|
+
p.log.step("Provisioning infrastructure...");
|
|
479
|
+
const provisioner = new CLIProvisioner();
|
|
480
|
+
const result = await provisioner.provision(plan);
|
|
594
481
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
baseSecrets[workerName][secretName] = val;
|
|
482
|
+
if (result.success) {
|
|
483
|
+
formatSuccess(`Created: ${result.created.join(", ")}`, globalOpts);
|
|
484
|
+
} else {
|
|
485
|
+
p.log.warn(`Provisioning had issues: ${result.errors.join("; ")}`);
|
|
600
486
|
}
|
|
487
|
+
|
|
488
|
+
engine.execute({ provisioningResults: result });
|
|
489
|
+
} else {
|
|
490
|
+
engine.execute({
|
|
491
|
+
provisioningResults: { success: true, created: [], errors: [] },
|
|
492
|
+
});
|
|
601
493
|
}
|
|
494
|
+
} else {
|
|
495
|
+
engine.execute({});
|
|
602
496
|
}
|
|
497
|
+
await saveState(engine);
|
|
603
498
|
}
|
|
604
499
|
|
|
605
|
-
// Step
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
500
|
+
// ── Step 4: Secrets ──────────────────────────────────────────────────
|
|
501
|
+
if (engine.getCurrentStep().id === "SECRETS") {
|
|
502
|
+
const state = engine.getState();
|
|
503
|
+
const collectedSecrets: Record<string, Record<string, string>> = {};
|
|
504
|
+
|
|
505
|
+
if (state.selectedIntegrations.length > 0) {
|
|
506
|
+
p.log.step("Collecting integration secrets...");
|
|
507
|
+
|
|
508
|
+
for (const key of state.selectedIntegrations) {
|
|
509
|
+
const { INTEGRATIONS } =
|
|
510
|
+
await import("@jango-blockchained/hoox-shared");
|
|
511
|
+
const integration = INTEGRATIONS.find((i) => i.key === key);
|
|
512
|
+
if (!integration || Object.keys(integration.secrets).length === 0)
|
|
513
|
+
continue;
|
|
514
|
+
|
|
515
|
+
const secretEntries = Object.entries(integration.secrets);
|
|
516
|
+
const groupFields: Record<string, () => Promise<string | symbol>> = {};
|
|
517
|
+
|
|
518
|
+
for (const [secretName] of secretEntries) {
|
|
519
|
+
groupFields[secretName] = () =>
|
|
520
|
+
p.password({
|
|
521
|
+
message: `${integration.secrets[secretName]}:`,
|
|
522
|
+
validate(value) {
|
|
523
|
+
if (!value) return "This secret is required";
|
|
524
|
+
return;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const collected = await p.group(groupFields, {
|
|
530
|
+
onCancel: () => {
|
|
531
|
+
p.cancel("Setup cancelled.");
|
|
532
|
+
process.exit(0);
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
collectedSecrets[key] = {};
|
|
537
|
+
for (const [secretName] of secretEntries) {
|
|
538
|
+
const val = collected[secretName];
|
|
539
|
+
collectedSecrets[key][secretName] =
|
|
540
|
+
typeof val === "string" ? val : "";
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
617
544
|
|
|
618
|
-
|
|
619
|
-
|
|
545
|
+
engine.execute({ secrets: collectedSecrets });
|
|
546
|
+
await saveState(engine);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Step 5: Config Write ──────────────────────────────────────────────
|
|
550
|
+
if (engine.getCurrentStep().id === "CONFIG_WRITE") {
|
|
551
|
+
p.log.step("Writing configuration...");
|
|
552
|
+
|
|
553
|
+
const config = engine.buildConfig();
|
|
554
|
+
const state = engine.getState();
|
|
555
|
+
|
|
556
|
+
await writeWorkersJsonc(config, globalOpts);
|
|
557
|
+
await createDevVars(config, state.secrets, globalOpts);
|
|
558
|
+
|
|
559
|
+
engine.execute({});
|
|
560
|
+
await cleanupState(); // Clean up state file — wizard complete
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Step 6: Deploy ──────────────────────────────────────────────────
|
|
564
|
+
if (engine.getCurrentStep().id === "DEPLOY") {
|
|
565
|
+
const shouldDeploy = await p.confirm({
|
|
566
|
+
message: "Deploy workers now?",
|
|
567
|
+
initialValue: false,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
if (p.isCancel(shouldDeploy)) {
|
|
571
|
+
p.cancel("Setup cancelled.");
|
|
572
|
+
process.exit(0);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (shouldDeploy) {
|
|
576
|
+
p.log.step("Deploying workers...");
|
|
577
|
+
const proc = Bun.spawn(["hoox", "deploy", "all"], {
|
|
578
|
+
stdout: "inherit",
|
|
579
|
+
stderr: "inherit",
|
|
580
|
+
});
|
|
581
|
+
await proc.exited;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
engine.execute({});
|
|
585
|
+
}
|
|
620
586
|
|
|
621
|
-
// Done
|
|
587
|
+
// ── Done ──────────────────────────────────────────────────────────────
|
|
622
588
|
p.outro(
|
|
623
589
|
theme.success("Setup complete! Run ") +
|
|
624
590
|
theme.bold("hoox check setup") +
|