@helyx/cli 1.5.0 → 1.6.1

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.
@@ -0,0 +1,747 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ link,
4
+ lstat,
5
+ open,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ unlink,
10
+ } from "node:fs/promises";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import { Writable } from "node:stream";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { parseEnv } from "node:util";
16
+
17
+ const scriptDirectory = dirname(fileURLToPath(import.meta.url));
18
+ class WizardError extends Error {}
19
+ const encryptionKey = "HELYX_DATA_ENCRYPTION_KEY";
20
+ const secretFields = new Set([
21
+ encryptionKey,
22
+ "DATABASE_URL",
23
+ "DISCORD_TOKEN",
24
+ "PGPASSWORD",
25
+ "POSTGRES_PASSWORD",
26
+ ]);
27
+ const placeholder =
28
+ /replace[- ]|12345678901234567|^mysql:\/\/user:password@host/iu;
29
+ const safeDisplay = (value) => value.replace(/[\p{Cc}\p{Cf}]/gu, "?");
30
+
31
+ export function maskSecret(value) {
32
+ const chars = Array.from(value);
33
+ if (chars.length <= 4) return "*".repeat(chars.length);
34
+ if (chars.length === 5) return `***${safeDisplay(chars.slice(-2).join(""))}`;
35
+ const prefix = Math.min(5, chars.length - 4);
36
+ return `${safeDisplay(chars.slice(0, prefix).join(""))}${"*".repeat(chars.length - prefix - 3)}${safeDisplay(chars.slice(-3).join(""))}`;
37
+ }
38
+
39
+ async function optionalRead(path) {
40
+ try {
41
+ const info = await lstat(path);
42
+ if (!info.isFile() || info.isSymbolicLink())
43
+ throw new WizardError(
44
+ "Expected a regular file; symbolic links are not supported.",
45
+ );
46
+ return await readFile(path, "utf8");
47
+ } catch (error) {
48
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ export async function isStarter(directory) {
54
+ const example = await optionalRead(join(directory, ".env.example"));
55
+ if (example === null) return false;
56
+ const packageText = await optionalRead(join(directory, "package.json"));
57
+ if (!packageText) return false;
58
+ try {
59
+ return JSON.parse(packageText).name === "helyx-self-hosted";
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export async function discoverStarters(roots) {
66
+ const candidates = new Set();
67
+ const inspected = new Set();
68
+ for (const root of new Set(roots.map((path) => resolve(path)))) {
69
+ let entries;
70
+ try {
71
+ entries = await readdir(root, { withFileTypes: true });
72
+ } catch {
73
+ continue;
74
+ }
75
+ const paths = [
76
+ root,
77
+ ...entries
78
+ .filter(
79
+ (entry) =>
80
+ entry.isDirectory() &&
81
+ !entry.name.startsWith(".") &&
82
+ entry.name !== "node_modules",
83
+ )
84
+ .map((entry) => join(root, entry.name)),
85
+ ];
86
+ for (const path of paths) {
87
+ const identity = process.platform === "win32" ? path.toLowerCase() : path;
88
+ if (inspected.has(identity)) continue;
89
+ inspected.add(identity);
90
+ try {
91
+ if (await isStarter(path)) candidates.add(path);
92
+ } catch {
93
+ // An inaccessible neighbouring folder does not prevent manual selection.
94
+ }
95
+ }
96
+ }
97
+ return [...candidates].sort();
98
+ }
99
+
100
+ export function validateDatabaseUrl(value, engine) {
101
+ try {
102
+ if (/\s/u.test(value) || placeholder.test(value)) throw new WizardError();
103
+ const url = new URL(value);
104
+ const protocols =
105
+ engine === "mysql" ? ["mysql:"] : ["postgres:", "postgresql:"];
106
+ if (
107
+ !protocols.includes(url.protocol) ||
108
+ !url.hostname ||
109
+ url.pathname.length < 2 ||
110
+ url.hash ||
111
+ (url.port && Number(url.port) < 1)
112
+ )
113
+ throw new WizardError();
114
+ decodeURIComponent(url.username);
115
+ decodeURIComponent(url.password);
116
+ } catch {
117
+ throw new WizardError(
118
+ `Enter a ${engine} URL with a host, database name and valid port. Percent-encode special characters in credentials.`,
119
+ );
120
+ }
121
+ }
122
+
123
+ // Use single quotes to retain #, $, spaces and backslashes literally in Node
124
+ // and Compose. Unusual mixed-quote values require manual editing, not guessing.
125
+ export function encodeValue(value) {
126
+ if (/[\p{Cc}\p{Cf}]/u.test(value))
127
+ throw new WizardError(
128
+ "Use a single-line value without control characters.",
129
+ );
130
+ if (!value.includes("'")) return `'${value}'`;
131
+ if (!/["\\$]/u.test(value)) return `"${value}"`;
132
+ throw new WizardError(
133
+ "This value needs manual .env quoting. For URLs, percent-encode quote characters.",
134
+ );
135
+ }
136
+
137
+ function records(content) {
138
+ const lines = content.match(/[^\r\n]*(?:\r\n|\n|$)/gu)?.filter(Boolean) ?? [];
139
+ const result = [];
140
+ for (let index = 0; index < lines.length; index++) {
141
+ let text = lines[index];
142
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)/u.exec(
143
+ text,
144
+ );
145
+ const key = match?.[1];
146
+ const quote = match?.[2]?.[0];
147
+ let comment = match?.[2]?.match(/\s+#.*$/u)?.[0] ?? "";
148
+ if (quote === "'" || quote === '"' || quote === "`") {
149
+ let tail = match[2].slice(1);
150
+ while (!tail.includes(quote) && index + 1 < lines.length) {
151
+ const next = lines[++index];
152
+ text += next;
153
+ tail += next;
154
+ }
155
+ if (!tail.includes(quote))
156
+ throw new WizardError(
157
+ "The environment file has an unclosed quoted value. Correct it before continuing.",
158
+ );
159
+ comment =
160
+ tail.slice(tail.indexOf(quote) + 1).match(/^[\t ]*#.*$/mu)?.[0] ?? "";
161
+ }
162
+ result.push({ key, text, comment });
163
+ }
164
+ return result;
165
+ }
166
+
167
+ export function updateEnvironment(content, changes) {
168
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
169
+ const remaining = new Map(Object.entries(changes));
170
+ const seen = new Set();
171
+ const output = records(content)
172
+ .map(({ key, text, comment }) => {
173
+ if (!Object.hasOwn(changes, key)) return text;
174
+ if (seen.has(key)) return ""; // Remove duplicate active assignments.
175
+ seen.add(key);
176
+ remaining.delete(key);
177
+ return `${key}=${encodeValue(changes[key])}${comment}${newline}`;
178
+ })
179
+ .join("");
180
+ return (
181
+ output +
182
+ (remaining.size && output && !output.endsWith("\n") ? newline : "") +
183
+ [...remaining]
184
+ .map(([key, value]) => `${key}=${encodeValue(value)}${newline}`)
185
+ .join("")
186
+ );
187
+ }
188
+
189
+ const environmentGroups = [
190
+ [
191
+ "INSTALLATION",
192
+ /^(?:HELYX_RUNTIME_PROFILE|HELYX_DEPLOYMENT_ENVIRONMENT|NODE_ENV)$/u,
193
+ ],
194
+ ["DISCORD BOT", /^(?:DISCORD_TOKEN|DISCORD_CLIENT_ID)$/u],
195
+ [
196
+ "DISCORD SERVER COMMANDS",
197
+ /^(?:HELYX_GUILD_COMMAND_|HELYX_GLOBAL_COMMAND_MODE)/u,
198
+ ],
199
+ [
200
+ "DISCORD GATEWAY INTENTS",
201
+ /^HELYX_(?:MESSAGE_CONTENT_ENABLED|GUILD_MEMBERS_ENABLED)$/u,
202
+ ],
203
+ ["DATABASE CONNECTION", /^DATABASE_|^HELYX_DATABASE_STARTUP_MODE$/u],
204
+ ["BUNDLED DOCKER POSTGRESQL", /^(?:PGUSER|PGPASSWORD|POSTGRES_)/u],
205
+ [
206
+ "DASHBOARD AND OWNERS",
207
+ /^(?:HELYX_CLOUD_CONNECT_ENABLED|HELYX_DASHBOARD_PUBLIC_URL|HELYX_OWNER_USER_IDS)$/u,
208
+ ],
209
+ ["DATA ENCRYPTION - KEEP THIS KEY", /^HELYX_DATA_ENCRYPTION_KEY$/u],
210
+ ["LOGGING", /^LOG_LEVEL$/u],
211
+ ["SHARDING - ADVANCED", /^HELYX_SHARD_/u],
212
+ ["MODULE SELECTION", /^HELYX_MODULE_/u],
213
+ ["CUSTOM SETTINGS", /./u],
214
+ ];
215
+ const omittedExampleSetting =
216
+ /^(?:HELYX_AUTOMOD_|HELYX_BOT_CONTROL_|HELYX_HOSTED_MODERATION_ENABLED$|HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS$|HELYX_CLOUD_(?!CONNECT_ENABLED$)|HELYX_OAUTH_|HELYX_STATUS_|CF_ACCESS_|DISCORD_INTERNAL_GUILD_ID$|APPROVED_ROLES$)/u;
217
+ const oldHeadings = new Set([
218
+ "AUTO MODERATION",
219
+ "PRIVATE CONTROL - ADVANCED",
220
+ "CLOUD CONNECT - ADVANCED",
221
+ "OAUTH - ADVANCED",
222
+ "STATUS REPORTING - OPTIONAL",
223
+ "INTERNAL DEVELOPMENT - OPTIONAL",
224
+ "REQUIRED — EDIT THESE VALUES",
225
+ "Discord",
226
+ "Database",
227
+ "OPTIONAL EVERYDAY SETTINGS",
228
+ "SHARDING: OFF BY DEFAULT",
229
+ "ADVANCED INTEGRATIONS — EDIT ONLY WHEN NEEDED",
230
+ "DANGER ZONE — HELYX-MANAGED VALUES, DO NOT EDIT",
231
+ "Runtime mode",
232
+ "Helyx self-hosted environment",
233
+ "Keep this file private. Lines starting with # are comments.",
234
+ ]);
235
+
236
+ export function formatEnvironment(content, { template = false } = {}) {
237
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
238
+ const groups = environmentGroups.map(() => []);
239
+ let pending = [];
240
+ let previousGroup;
241
+ const append = (index, lines) => {
242
+ const text = lines.join("\n").trim();
243
+ if (!text) return;
244
+ if (
245
+ groups[index].length &&
246
+ text.startsWith("#") &&
247
+ !/^#\s*[A-Za-z_][A-Za-z0-9_]*\s*=/u.test(text)
248
+ )
249
+ groups[index].push("");
250
+ groups[index].push(text);
251
+ };
252
+ for (const record of records(content)) {
253
+ const text = record.text.replace(/\r\n/gu, "\n").replace(/\n$/u, "");
254
+ const heading = text.match(/^#\s*(.*?)\s*$/u)?.[1];
255
+ if (
256
+ heading !== undefined &&
257
+ (oldHeadings.has(heading) ||
258
+ /^=+$/u.test(heading) ||
259
+ environmentGroups.some(([title]) => heading === title))
260
+ )
261
+ continue;
262
+ const key =
263
+ record.key ?? text.match(/^\s*#\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/u)?.[1];
264
+ if (key && omittedExampleSetting.test(key) && (template || !record.key)) {
265
+ pending = [];
266
+ continue;
267
+ }
268
+ if (!key) {
269
+ if (!text.trim() && pending.length && previousGroup !== undefined) {
270
+ append(previousGroup, pending);
271
+ pending = [];
272
+ } else if (text.trim()) pending.push(text);
273
+ continue;
274
+ }
275
+ const group = environmentGroups.findIndex(([, pattern]) =>
276
+ pattern.test(key),
277
+ );
278
+ append(group, [...pending, text]);
279
+ pending = [];
280
+ previousGroup = group;
281
+ }
282
+ if (pending.length) append(previousGroup ?? groups.length - 1, pending);
283
+ const divider =
284
+ "# ============================================================================";
285
+ const output =
286
+ [
287
+ "# Helyx self-hosted environment\n# Keep this file private. Lines starting with # are comments.",
288
+ ...groups.flatMap((lines, index) =>
289
+ lines.length
290
+ ? [
291
+ `${divider}\n# ${environmentGroups[index][0]}\n${divider}\n\n${lines.join("\n")}`,
292
+ ]
293
+ : [],
294
+ ),
295
+ ].join("\n\n\n") + "\n";
296
+ const formatted = output.replace(/\n/gu, newline);
297
+ const before = parseEnv(content);
298
+ if (template)
299
+ for (const key of Object.keys(before))
300
+ if (omittedExampleSetting.test(key)) delete before[key];
301
+ const after = parseEnv(formatted);
302
+ if (
303
+ Object.keys(before).length !== Object.keys(after).length ||
304
+ Object.entries(before).some(([key, value]) => after[key] !== value)
305
+ )
306
+ throw new WizardError(
307
+ "The environment could not be regrouped without changing values. No changes were saved.",
308
+ );
309
+ return formatted;
310
+ }
311
+
312
+ export async function saveEnvironment(directory, original, content) {
313
+ const destination = join(directory, ".env");
314
+ const temporary = join(
315
+ directory,
316
+ `.env-builder-${randomBytes(8).toString("hex")}.tmp`,
317
+ );
318
+ let handle;
319
+ try {
320
+ if ((await optionalRead(destination)) !== original)
321
+ throw new WizardError(
322
+ "The .env file changed while the wizard was open. Run the builder again.",
323
+ );
324
+ handle = await open(temporary, "wx", 0o600);
325
+ await handle.writeFile(content, "utf8");
326
+ await handle.sync();
327
+ await handle.close();
328
+ handle = undefined;
329
+ if ((await optionalRead(destination)) !== original)
330
+ throw new WizardError(
331
+ "The .env file changed while the wizard was open. Run the builder again.",
332
+ );
333
+ if (original === null) {
334
+ // Publish the complete file exclusively, without a partially written .env.
335
+ await link(temporary, destination);
336
+ } else {
337
+ await rename(temporary, destination);
338
+ }
339
+ } finally {
340
+ await handle?.close();
341
+ await unlink(temporary).catch((error) => {
342
+ if (error.code !== "ENOENT") throw error;
343
+ });
344
+ }
345
+ }
346
+
347
+ export function terminalIO() {
348
+ return {
349
+ print: (text) => console.log(text),
350
+ ask: (prompt, { secret = false } = {}) =>
351
+ new Promise((accept, reject) => {
352
+ const muted = new Writable({
353
+ write(_chunk, _encoding, callback) {
354
+ callback();
355
+ },
356
+ });
357
+ const rl = createInterface({
358
+ input: process.stdin,
359
+ output: secret ? muted : process.stdout,
360
+ terminal: true,
361
+ historySize: 0,
362
+ });
363
+ let answered = false;
364
+ if (secret) process.stdout.write(prompt);
365
+ rl.on("SIGINT", () => {
366
+ rl.close();
367
+ });
368
+ rl.on("close", () => {
369
+ if (!answered)
370
+ reject(new WizardError("Cancelled. No changes were saved."));
371
+ });
372
+ rl.question(secret ? "" : prompt, (answer) => {
373
+ answered = true;
374
+ rl.close();
375
+ if (secret) process.stdout.write("\n");
376
+ accept(answer.trim());
377
+ });
378
+ }),
379
+ };
380
+ }
381
+
382
+ export async function runWizard({
383
+ roots = [process.cwd(), scriptDirectory],
384
+ directory,
385
+ io = terminalIO(),
386
+ } = {}) {
387
+ io.print("\nHELYX ENVIRONMENT BUILDER\n");
388
+ io.print(
389
+ "Credentials stay on this computer. No uploads, telemetry or connection tests.\nDatabase URL and Discord token input is visible so you can check typing and pasting. Password input is hidden.\nSaved secrets stay masked in prompts and the summary. Nothing is saved until you confirm.\nPress Ctrl+C at any question to cancel.\n",
390
+ );
391
+ if (!directory) {
392
+ const candidates = await discoverStarters(roots);
393
+ if (candidates.length === 1) directory = candidates[0];
394
+ else if (candidates.length > 1) {
395
+ io.print("Starter folders found:");
396
+ candidates.forEach((candidate, index) =>
397
+ io.print(`${index + 1}. ${safeDisplay(candidate)}`),
398
+ );
399
+ while (!directory) {
400
+ const selected = await io.ask(
401
+ "Choose a starter number, or P to enter a path: ",
402
+ );
403
+ if (selected.toLowerCase() === "p") break;
404
+ if (/^[1-9]\d*$/u.test(selected))
405
+ directory = candidates[Number(selected) - 1];
406
+ }
407
+ }
408
+ while (!directory) {
409
+ const answer = await io.ask(
410
+ "Paste or drag your extracted starter folder here: ",
411
+ );
412
+ const candidate = resolve(answer.replace(/^"(.*)"$/u, "$1"));
413
+ if (await isStarter(candidate)) directory = candidate;
414
+ else
415
+ io.print(
416
+ "That folder needs a Helyx starter package.json and .env.example. The Helyx source repository is not a starter.",
417
+ );
418
+ }
419
+ }
420
+ directory = resolve(directory);
421
+ if (!(await isStarter(directory)))
422
+ throw new WizardError(
423
+ "Choose a downloaded Helyx starter folder containing package.json and .env.example.",
424
+ );
425
+ const original = await optionalRead(join(directory, ".env"));
426
+ const template = formatEnvironment(
427
+ await readFile(join(directory, ".env.example"), "utf8"),
428
+ { template: true },
429
+ );
430
+ const existing = parseEnv(original ?? "");
431
+ const defaults = parseEnv(template);
432
+ const values = { ...defaults, ...existing };
433
+ const changes = {};
434
+ io.print(
435
+ `Starter: ${safeDisplay(directory)}\n${original === null ? "Creating a new .env." : "Updating the existing .env; Enter keeps each current value."}`,
436
+ );
437
+ const askValue = async (
438
+ key,
439
+ label,
440
+ {
441
+ secret = false,
442
+ visibleInput = false,
443
+ validate = () => {},
444
+ fallback,
445
+ optional = false,
446
+ } = {},
447
+ ) => {
448
+ const current = fallback ?? values[key] ?? "";
449
+ const display = current
450
+ ? secret
451
+ ? maskSecret(current)
452
+ : safeDisplay(current)
453
+ : "none";
454
+ for (;;) {
455
+ const answer = await io.ask(
456
+ `${label} [${display}]${optional ? " (Enter keeps; - clears)" : " (Enter keeps)"}: `,
457
+ { secret: secret && !visibleInput },
458
+ );
459
+ const value = optional && answer === "-" ? "" : answer || current;
460
+ try {
461
+ if (placeholder.test(value))
462
+ throw new WizardError(
463
+ "Replace the example placeholder with your own value.",
464
+ );
465
+ if (!optional && !value) throw new WizardError("A value is required.");
466
+ encodeValue(value);
467
+ validate(value);
468
+ values[key] = value;
469
+ if (value !== existing[key]) changes[key] = value;
470
+ return value;
471
+ } catch (error) {
472
+ io.print(error.message);
473
+ }
474
+ }
475
+ };
476
+ const choice = (options) => (value) => {
477
+ if (!options.includes(value))
478
+ throw new WizardError(`Choose ${options.join(" or ")}.`);
479
+ };
480
+ const previousEngine = values.DATABASE_ENGINE;
481
+ const engine = await askValue(
482
+ "DATABASE_ENGINE",
483
+ "Database engine (change if needed)",
484
+ { validate: choice(["postgresql", "mysql"]) },
485
+ );
486
+ if (engine !== defaults.DATABASE_ENGINE)
487
+ io.print(
488
+ "DATABASE CHANGED: this tool updates .env only. Use a matching starter before running Helyx; Compose and module files are not converted.",
489
+ );
490
+ const bundledAvailable =
491
+ engine === "postgresql" &&
492
+ defaults.DATABASE_ENGINE === "postgresql" &&
493
+ (await optionalRead(join(directory, "compose.yaml"))) !== null &&
494
+ Boolean(defaults.POSTGRES_PASSWORD);
495
+ let databaseMode = "external";
496
+ if (bundledAvailable) {
497
+ for (;;) {
498
+ databaseMode =
499
+ (await io.ask(
500
+ `Database source: bundled Docker PostgreSQL or external URL [${values.DATABASE_URL === "postgresql://database:5432/helyx" ? "bundled" : "external"}]: `,
501
+ )) ||
502
+ (values.DATABASE_URL === "postgresql://database:5432/helyx"
503
+ ? "bundled"
504
+ : "external");
505
+ if (["bundled", "external"].includes(databaseMode)) break;
506
+ io.print("Choose bundled or external.");
507
+ }
508
+ }
509
+ if (databaseMode === "bundled") {
510
+ io.print(
511
+ "Bundled PostgreSQL: use the existing database username/name; changing a password does not update an already-initialised database volume.",
512
+ );
513
+ const password = await askValue(
514
+ "POSTGRES_PASSWORD",
515
+ "Database password (16+ letters, digits, _ or -)",
516
+ {
517
+ secret: true,
518
+ fallback:
519
+ existing.POSTGRES_PASSWORD &&
520
+ !placeholder.test(existing.POSTGRES_PASSWORD)
521
+ ? existing.POSTGRES_PASSWORD
522
+ : randomBytes(24).toString("hex"),
523
+ validate: (value) => {
524
+ if (!/^[A-Za-z0-9_-]{16,}$/u.test(value))
525
+ throw new WizardError(
526
+ "Use at least 16 letters, digits, underscores or hyphens.",
527
+ );
528
+ },
529
+ },
530
+ );
531
+ const user = values.POSTGRES_USER || "helyx";
532
+ const name = values.POSTGRES_DB || "helyx";
533
+ if (!/^[A-Za-z0-9_-]+$/u.test(user) || !/^[A-Za-z0-9_-]+$/u.test(name))
534
+ throw new WizardError(
535
+ "The existing Docker database user/name needs manual configuration.",
536
+ );
537
+ Object.assign(changes, {
538
+ PGPASSWORD: password,
539
+ PGUSER: user,
540
+ POSTGRES_USER: user,
541
+ POSTGRES_DB: name,
542
+ DATABASE_URL: "postgresql://database:5432/helyx",
543
+ DATABASE_SSL: "disable",
544
+ });
545
+ } else {
546
+ await askValue(
547
+ "DATABASE_URL",
548
+ "Database connection URL (typing and paste visible)",
549
+ {
550
+ secret: true,
551
+ visibleInput: true,
552
+ fallback:
553
+ engine !== previousEngine ||
554
+ (values.DATABASE_URL ?? "").includes("://database:")
555
+ ? ""
556
+ : values.DATABASE_URL,
557
+ validate: (value) => validateDatabaseUrl(value, engine),
558
+ },
559
+ );
560
+ io.print(
561
+ "SSL: disable = no TLS; require = TLS without certificate verification; verify-full = TLS with certificate verification. Use your provider's requirement.",
562
+ );
563
+ await askValue("DATABASE_SSL", "Database SSL mode", {
564
+ fallback: existing.DATABASE_SSL ?? "verify-full",
565
+ validate: choice(["disable", "require", "verify-full"]),
566
+ });
567
+ }
568
+ await askValue("DISCORD_CLIENT_ID", "Discord application ID", {
569
+ validate: (value) => {
570
+ if (!/^\d{17,20}$/u.test(value))
571
+ throw new WizardError("Enter the 17-20 digit Discord application ID.");
572
+ },
573
+ });
574
+ io.print(
575
+ "Enter the Discord server ID for your bot's server-specific slash commands. For several servers, separate IDs with commas. You can choose whether to enable these commands next.",
576
+ );
577
+ const validateGuildIds = (value) => {
578
+ if (value && !/^\d{17,20}(?:,\d{17,20})*$/u.test(value))
579
+ throw new WizardError(
580
+ "Use 17-20 digit Discord server IDs, separated by commas without spaces.",
581
+ );
582
+ };
583
+ let guildIds = await askValue(
584
+ "HELYX_GUILD_COMMAND_WRITE_GUILD_IDS",
585
+ "Discord server (guild) ID(s), optional",
586
+ {
587
+ optional: true,
588
+ validate: validateGuildIds,
589
+ },
590
+ );
591
+ const currentCommandChoice =
592
+ values.HELYX_GUILD_COMMAND_WRITES_ENABLED === "true" ? "yes" : "no";
593
+ let enableServerCommands;
594
+ for (;;) {
595
+ const answer =
596
+ (
597
+ await io.ask(
598
+ `Enable server-specific slash commands for these servers? Yes/No [${currentCommandChoice}]: `,
599
+ )
600
+ ).toLowerCase() || currentCommandChoice;
601
+ if (["yes", "y", "no", "n"].includes(answer)) {
602
+ enableServerCommands = answer === "yes" || answer === "y";
603
+ break;
604
+ }
605
+ io.print("Choose Yes or No. Enter keeps the choice shown in brackets.");
606
+ }
607
+ if (enableServerCommands) {
608
+ if (!guildIds) {
609
+ io.print(
610
+ "At least one Discord server ID is needed to enable server-specific slash commands.",
611
+ );
612
+ guildIds = await askValue(
613
+ "HELYX_GUILD_COMMAND_WRITE_GUILD_IDS",
614
+ "Discord server (guild) ID(s)",
615
+ { validate: validateGuildIds },
616
+ );
617
+ }
618
+ Object.assign(changes, {
619
+ HELYX_GUILD_COMMAND_WRITE_GUILD_IDS: guildIds,
620
+ HELYX_GUILD_COMMAND_WRITES_ENABLED: "true",
621
+ HELYX_GUILD_COMMAND_WRITE_SCOPE: "allowlist",
622
+ HELYX_GLOBAL_COMMAND_MODE: "platform-only",
623
+ });
624
+ io.print(
625
+ "Server-specific slash commands will be enabled for the listed servers when Helyx starts. Core commands remain global.",
626
+ );
627
+ } else {
628
+ changes.HELYX_GUILD_COMMAND_WRITES_ENABLED = "false";
629
+ io.print("Server-specific slash command registration will be disabled.");
630
+ }
631
+ await askValue(
632
+ "DISCORD_TOKEN",
633
+ "Discord bot token (typing and paste visible)",
634
+ {
635
+ secret: true,
636
+ visibleInput: true,
637
+ validate: (value) => {
638
+ if (/\s/u.test(value) || /^(?:Bot|Bearer)\b/iu.test(value))
639
+ throw new WizardError(
640
+ "Paste the bot token only, without spaces or a Bot/Bearer prefix.",
641
+ );
642
+ },
643
+ },
644
+ );
645
+ io.print(
646
+ "Enable gateway intents only if required by your modules and enabled in the Discord Developer Portal.",
647
+ );
648
+ for (const [key, label] of [
649
+ ["HELYX_GUILD_MEMBERS_ENABLED", "Server Members Intent (true/false)"],
650
+ ["HELYX_MESSAGE_CONTENT_ENABLED", "Message Content Intent (true/false)"],
651
+ [
652
+ "HELYX_CLOUD_CONNECT_ENABLED",
653
+ "Allow optional Cloud Connect pairing (true/false)",
654
+ ],
655
+ ]) {
656
+ await askValue(key, label, { validate: choice(["true", "false"]) });
657
+ }
658
+ await askValue(
659
+ "HELYX_OWNER_USER_IDS",
660
+ "Additional owner Discord IDs (comma-separated, optional)",
661
+ {
662
+ optional: true,
663
+ validate: (value) => {
664
+ if (
665
+ value &&
666
+ (!/^\d{17,20}(?:,\d{17,20})*$/u.test(value) ||
667
+ value.split(",").length > 20)
668
+ )
669
+ throw new WizardError(
670
+ "Use comma-separated 17-20 digit user IDs, without spaces.",
671
+ );
672
+ },
673
+ },
674
+ );
675
+ if (existing[encryptionKey] && !placeholder.test(existing[encryptionKey])) {
676
+ const key = existing[encryptionKey];
677
+ if (
678
+ Buffer.from(key, "base64").length !== 32 ||
679
+ Buffer.from(key, "base64").toString("base64") !== key
680
+ )
681
+ throw new WizardError(
682
+ "The existing data-encryption key has an invalid format. It was not replaced; restore the correct key before continuing.",
683
+ );
684
+ io.print(`Keeping existing data-encryption key: ${maskSecret(key)}`);
685
+ } else changes[encryptionKey] = randomBytes(32).toString("base64");
686
+ Object.assign(changes, {
687
+ HELYX_RUNTIME_PROFILE: "self-hosted",
688
+ HELYX_DEPLOYMENT_ENVIRONMENT: "self-hosted",
689
+ });
690
+ if (engine === "mysql") Object.assign(changes, { HELYX_SHARD_MODE: "off" });
691
+ const updated = formatEnvironment(
692
+ updateEnvironment(original ?? template, changes),
693
+ );
694
+ for (const [key, value] of Object.entries(changes))
695
+ if (parseEnv(updated)[key] !== value)
696
+ throw new WizardError(
697
+ "The output could not be encoded safely. No changes were saved.",
698
+ );
699
+ io.print("\nReview changes (secrets masked):");
700
+ for (const [key, value] of Object.entries(changes))
701
+ io.print(
702
+ ` ${key}: ${existing[key] === undefined ? "not set" : secretFields.has(key) ? maskSecret(existing[key]) : safeDisplay(existing[key])} -> ${secretFields.has(key) ? maskSecret(value) : safeDisplay(value) || "none"}`,
703
+ );
704
+ io.print(`\nSave to: ${safeDisplay(join(directory, ".env"))}`);
705
+ if (
706
+ (await io.ask("Save this .env? Type SAVE to confirm: ")).toUpperCase() !==
707
+ "SAVE"
708
+ ) {
709
+ io.print("Cancelled. No changes were saved.");
710
+ return { saved: false, directory };
711
+ }
712
+ await saveEnvironment(directory, original, updated);
713
+ io.print(
714
+ "\nSaved .env. Configuration format checked; no database or Discord connection was tested.\nKeep this file and its data-encryption key private and backed up.\nThe builder did not install packages, start Helyx or change other starter files.",
715
+ );
716
+ return { saved: true, directory };
717
+ }
718
+
719
+ export async function runEnvironmentBuilder(directory) {
720
+ if (Number(process.versions.node.split(".")[0]) < 24) {
721
+ console.error("Node.js 24 or newer is required.");
722
+ process.exitCode = 1;
723
+ } else if (!process.stdin.isTTY || !process.stdout.isTTY) {
724
+ console.error(
725
+ "Open this builder in an interactive terminal or use the launcher for your platform.",
726
+ );
727
+ process.exitCode = 1;
728
+ } else {
729
+ try {
730
+ await runWizard({ directory });
731
+ } catch (error) {
732
+ console.error(
733
+ error instanceof WizardError
734
+ ? error.message
735
+ : "The builder stopped without completing. Check file access and the starter's environment syntax. No credential values are included in errors.",
736
+ );
737
+ process.exitCode = 1;
738
+ }
739
+ }
740
+ }
741
+
742
+ if (
743
+ process.argv[1] &&
744
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
745
+ ) {
746
+ await runEnvironmentBuilder(process.argv[2]);
747
+ }