@helyx/cli 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add public `/proc` process information for hosted and self-hosted bots, replacing
8
+ the private `/dev proc` path. Keep the reply private and independent of database
9
+ availability.
10
+
11
+ Add a PebbleHost starter profile with direct Node CLI scripts, flat starter ZIPs,
12
+ compact quoted environment examples and matching guided environment builder
13
+ output. Preserve existing custom settings and encryption keys when rebuilding
14
+ an environment file, and recognize quoted placeholders during setup.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - @helyx/bot@1.6.0
20
+
21
+ ## 1.6.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Remove unused internal development, status reporting, OAuth, private control,
26
+ advanced Cloud Connect and Auto Moderation tuning examples from the self-host
27
+ environment template and portable documentation. The environment builder also
28
+ omits these examples from older templates while preserving existing active
29
+ custom settings. Keep Discord intent settings, basic Cloud Connect and data
30
+ encryption configuration; runtime defaults and scanning behavior are unchanged.
31
+
3
32
  ## 1.6.0
4
33
 
5
34
  ### Minor Changes
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { type SelfHostEnvironmentBuilder } from "./environment-builders.js";
3
3
  import { type SelfHostCatalogue, type SelfHostDatabaseEngine } from "./catalogue.js";
4
+ export declare const selfHostProviderSchema: z.ZodEnum<{
5
+ standard: "standard";
6
+ pebblehost: "pebblehost";
7
+ }>;
4
8
  export declare const selfHostBuildManifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
9
  schemaVersion: z.ZodLiteral<1>;
6
10
  environmentBuilder: z.ZodOptional<z.ZodEnum<{
@@ -10,6 +14,10 @@ export declare const selfHostBuildManifestSchema: z.ZodDiscriminatedUnion<[z.Zod
10
14
  unix: "unix";
11
15
  all: "all";
12
16
  }>>;
17
+ provider: z.ZodOptional<z.ZodEnum<{
18
+ standard: "standard";
19
+ pebblehost: "pebblehost";
20
+ }>>;
13
21
  catalogueVersion: z.ZodNumber;
14
22
  core: z.ZodObject<{
15
23
  package: z.ZodString;
@@ -33,6 +41,10 @@ export declare const selfHostBuildManifestSchema: z.ZodDiscriminatedUnion<[z.Zod
33
41
  unix: "unix";
34
42
  all: "all";
35
43
  }>>;
44
+ provider: z.ZodOptional<z.ZodEnum<{
45
+ standard: "standard";
46
+ pebblehost: "pebblehost";
47
+ }>>;
36
48
  databaseEngine: z.ZodOptional<z.ZodEnum<{
37
49
  postgresql: "postgresql";
38
50
  mysql: "mysql";
@@ -67,6 +79,6 @@ export declare const selfHostBuildManifestSchema: z.ZodDiscriminatedUnion<[z.Zod
67
79
  }, z.core.$strict>>;
68
80
  }, z.core.$strict>], "schemaVersion">;
69
81
  export type SelfHostBuildManifest = z.infer<typeof selfHostBuildManifestSchema>;
70
- export declare function createSelfHostBuildManifest(catalogue: SelfHostCatalogue, selectedModuleIds: readonly string[], databaseEngine?: SelfHostDatabaseEngine, environmentBuilder?: SelfHostEnvironmentBuilder): SelfHostBuildManifest;
82
+ export declare function createSelfHostBuildManifest(catalogue: SelfHostCatalogue, selectedModuleIds: readonly string[], databaseEngine?: SelfHostDatabaseEngine, environmentBuilder?: SelfHostEnvironmentBuilder, provider?: z.infer<typeof selfHostProviderSchema>): SelfHostBuildManifest;
71
83
  export declare function parseSelfHostBuildManifest(input: unknown, catalogue: SelfHostCatalogue): SelfHostBuildManifest;
72
84
  //# sourceMappingURL=manifest.d.ts.map
@@ -11,6 +11,7 @@ const officialModulePackage = z
11
11
  .string()
12
12
  .regex(/^@helyx\/module-[a-z0-9][a-z0-9-]*$/u);
13
13
  const moduleId = z.string().regex(/^helyx\.[a-z0-9][a-z0-9-]*$/u);
14
+ export const selfHostProviderSchema = z.enum(["standard", "pebblehost"]);
14
15
  const buildComponentSchema = z
15
16
  .object({ package: officialPackage, version: exactVersion })
16
17
  .strict();
@@ -25,6 +26,7 @@ const selfHostBuildManifestV1Schema = z
25
26
  .object({
26
27
  schemaVersion: z.literal(1),
27
28
  environmentBuilder: selfHostEnvironmentBuilderSchema.optional(),
29
+ provider: selfHostProviderSchema.optional(),
28
30
  catalogueVersion: z.number().int().positive(),
29
31
  core: buildComponentSchema,
30
32
  generator: buildComponentSchema,
@@ -51,6 +53,7 @@ const selfHostBuildManifestV2Schema = z
51
53
  .object({
52
54
  schemaVersion: z.literal(2),
53
55
  environmentBuilder: selfHostEnvironmentBuilderSchema.optional(),
56
+ provider: selfHostProviderSchema.optional(),
54
57
  databaseEngine: selfHostDatabaseEngineSchema.optional(),
55
58
  catalogueVersion: z.number().int().positive(),
56
59
  core: buildComponentSchema,
@@ -85,16 +88,17 @@ export const selfHostBuildManifestSchema = z
85
88
  packages.add(module.package);
86
89
  }
87
90
  });
88
- export function createSelfHostBuildManifest(catalogue, selectedModuleIds, databaseEngine = "postgresql", environmentBuilder = "none") {
91
+ export function createSelfHostBuildManifest(catalogue, selectedModuleIds, databaseEngine = "postgresql", environmentBuilder = "none", provider = "standard") {
89
92
  selfHostDatabaseEngineSchema.parse(databaseEngine);
90
93
  selfHostEnvironmentBuilderSchema.parse(environmentBuilder);
94
+ selfHostProviderSchema.parse(provider);
91
95
  assertPublishedComponent(catalogue.core, "Helyx runtime");
92
96
  assertPublishedComponent(catalogue.generator, "Helyx generator");
93
97
  if (new Set(selectedModuleIds).size !== selectedModuleIds.length) {
94
98
  throw new Error("Self-host selection contains duplicate module IDs");
95
99
  }
96
100
  if (catalogue.schemaVersion === 2) {
97
- return createSelfHostBuildManifestV2(catalogue, selectedModuleIds, databaseEngine, environmentBuilder);
101
+ return createSelfHostBuildManifestV2(catalogue, selectedModuleIds, databaseEngine, environmentBuilder, provider);
98
102
  }
99
103
  if (databaseEngine !== "postgresql") {
100
104
  throw new Error("Legacy self-host catalogue supports PostgreSQL only");
@@ -119,6 +123,7 @@ export function createSelfHostBuildManifest(catalogue, selectedModuleIds, databa
119
123
  });
120
124
  return {
121
125
  schemaVersion: 1,
126
+ ...(provider !== "standard" ? { provider } : {}),
122
127
  ...(environmentBuilder !== "none" ? { environmentBuilder } : {}),
123
128
  catalogueVersion: catalogue.catalogueVersion,
124
129
  core: {
@@ -132,7 +137,7 @@ export function createSelfHostBuildManifest(catalogue, selectedModuleIds, databa
132
137
  modules,
133
138
  };
134
139
  }
135
- function createSelfHostBuildManifestV2(catalogue, selectedModuleIds, databaseEngine, environmentBuilder = "none") {
140
+ function createSelfHostBuildManifestV2(catalogue, selectedModuleIds, databaseEngine, environmentBuilder = "none", provider = "standard") {
136
141
  const byId = new Map(catalogue.modules.map((module) => [module.id, module]));
137
142
  const explicitlySelected = new Set(selectedModuleIds);
138
143
  const modules = [];
@@ -194,6 +199,7 @@ function createSelfHostBuildManifestV2(catalogue, selectedModuleIds, databaseEng
194
199
  include(id);
195
200
  return {
196
201
  schemaVersion: 2,
202
+ ...(provider !== "standard" ? { provider } : {}),
197
203
  ...(environmentBuilder !== "none" ? { environmentBuilder } : {}),
198
204
  ...(databaseEngine === "mysql" ? { databaseEngine } : {}),
199
205
  catalogueVersion: catalogue.catalogueVersion,
@@ -224,9 +230,10 @@ export function parseSelfHostBuildManifest(input, catalogue) {
224
230
  const selectedModuleIds = manifest.modules
225
231
  .filter(({ moduleKind }) => moduleKind === "customer_feature")
226
232
  .map(({ id }) => id);
227
- const expected = createSelfHostBuildManifestV2(catalogue, selectedModuleIds, manifest.databaseEngine ?? "postgresql", manifest.environmentBuilder ?? "none");
233
+ const expected = createSelfHostBuildManifestV2(catalogue, selectedModuleIds, manifest.databaseEngine ?? "postgresql", manifest.environmentBuilder ?? "none", manifest.provider ?? "standard");
228
234
  if (!isDeepStrictEqual({
229
235
  ...manifest,
236
+ provider: manifest.provider === "standard" ? undefined : manifest.provider,
230
237
  environmentBuilder: manifest.environmentBuilder === "none"
231
238
  ? undefined
232
239
  : manifest.environmentBuilder,
@@ -235,6 +242,7 @@ export function parseSelfHostBuildManifest(input, catalogue) {
235
242
  : manifest.databaseEngine,
236
243
  }, {
237
244
  ...expected,
245
+ provider: expected.provider,
238
246
  databaseEngine: expected.databaseEngine,
239
247
  environmentBuilder: expected.environmentBuilder,
240
248
  })) {
@@ -22,6 +22,8 @@ type HostedOnlyBotEnvironment = "HELYX_PRIVACY_ENABLED" | "HELYX_PRIVACY_EXECUTO
22
22
  type EnvironmentExample = Record<Exclude<keyof BotEnvironmentInput, HostedOnlyBotEnvironment>, {
23
23
  value: string;
24
24
  optional: boolean;
25
+ } | {
26
+ omit: true;
25
27
  }>;
26
28
  export declare const selfHostEnvironmentContract: EnvironmentExample;
27
29
  export {};
@@ -63,6 +63,9 @@ async function writeGeneratedFiles(targetDirectory, manifest, catalogue, databas
63
63
  const databaseEngine = "databaseEngine" in manifest
64
64
  ? (manifest.databaseEngine ?? "postgresql")
65
65
  : "postgresql";
66
+ const databaseSsl = manifest.provider === "pebblehost" || databaseEngine === "postgresql"
67
+ ? "disable"
68
+ : "require";
66
69
  const dependencyEntries = [
67
70
  [manifest.core.package, manifest.core.version],
68
71
  [manifest.generator.package, manifest.generator.version],
@@ -90,6 +93,12 @@ async function writeGeneratedFiles(targetDirectory, manifest, catalogue, databas
90
93
  },
91
94
  dependencies,
92
95
  };
96
+ if (manifest.provider === "pebblehost") {
97
+ const cli = "node node_modules/@helyx/cli/dist/cli.js";
98
+ for (const key of Object.keys(packageJson.scripts)) {
99
+ packageJson.scripts[key] = packageJson.scripts[key].replace(/^helyx /u, `${cli} `);
100
+ }
101
+ }
93
102
  const moduleCatalogue = {
94
103
  schemaVersion: 1,
95
104
  packages: manifest.modules.map((module) => module.package),
@@ -105,12 +114,12 @@ async function writeGeneratedFiles(targetDirectory, manifest, catalogue, databas
105
114
  ? mysqlComposeTemplateUrl()
106
115
  : templateUrl(fileName), "utf8");
107
116
  const rendered = fileName === ".env.example"
108
- ? renderEnvironmentExample(template, databaseEngine)
117
+ ? renderEnvironmentExample(template, databaseEngine, databaseSsl)
109
118
  : fileName === "README.md"
110
119
  ? renderReadme(template, manifest, catalogue, databaseEngine)
111
120
  : template;
112
121
  if (fileName === ".env.example")
113
- validateEnvironmentTemplate(rendered, databaseEngine);
122
+ validateEnvironmentTemplate(rendered, databaseEngine, databaseSsl);
114
123
  const content = fileName.endsWith(".sh")
115
124
  ? rendered.replaceAll("\r\n", "\n")
116
125
  : normalizeCrLf(rendered);
@@ -120,15 +129,13 @@ async function writeGeneratedFiles(targetDirectory, manifest, catalogue, databas
120
129
  });
121
130
  }
122
131
  }
123
- function renderEnvironmentExample(template, databaseEngine) {
124
- if (databaseEngine === "postgresql")
125
- return template;
132
+ function renderEnvironmentExample(template, databaseEngine, databaseSsl) {
126
133
  return template
127
- .replaceAll("\r\n", "\n")
128
- .replace("# Docker: keep the URL. Direct Node.js/managed hosting: use your provider URL.\nDATABASE_ENGINE=postgresql\nDATABASE_URL=postgresql://database:5432/helyx", "# Use the MySQL-compatible database URL supplied by your provider.\n# The included Compose file runs only the bot; it never starts a second database.\nDATABASE_ENGINE=mysql\nDATABASE_URL=mysql://user:password@host:3306/helyx")
129
- .replace(/# Included Docker database[^\n]*\nPGUSER=helyx\nPGPASSWORD=replace-with-a-strong-password\nPOSTGRES_DB=helyx\nPOSTGRES_USER=helyx\nPOSTGRES_PASSWORD=replace-with-a-strong-password\n\n/u, "")
130
- .replace("HELYX_SHARD_MODE=off\n", "# MySQL-compatible self-hosting currently supports only this unsharded mode.\nHELYX_SHARD_MODE=off\n")
131
- .replace("DATABASE_SSL=disable", "DATABASE_SSL=require");
134
+ .replace("DATABASE_ENGINE='postgresql'", `DATABASE_ENGINE='${databaseEngine}'`)
135
+ .replace("DATABASE_URL='postgresql://database:5432/helyx'", databaseEngine === "mysql"
136
+ ? "DATABASE_URL='mysql://user:password@host:3306/helyx'"
137
+ : "DATABASE_URL='postgresql://database:5432/helyx'")
138
+ .replace("DATABASE_SSL='disable'", `DATABASE_SSL='${databaseSsl}'`);
132
139
  }
133
140
  function renderReadme(template, manifest, catalogue, databaseEngine) {
134
141
  const modulesById = new Map(catalogue.modules.map((module) => [module.id, module]));
@@ -146,11 +153,16 @@ function renderReadme(template, manifest, catalogue, databaseEngine) {
146
153
  let rendered = template
147
154
  .replaceAll("\r\n", "\n")
148
155
  .replace("{{SELECTED_MODULES}}", selected);
156
+ if (manifest.provider === "pebblehost") {
157
+ rendered =
158
+ "# PebbleHost setup\n\nThis starter uses the PebbleHost npm scripts. Extract the ZIP directly into the server root, select Node.js 24.7.0 and set Bot Start File to `package.json`. Create your `.env` at https://helyx.gg/env-builder/?provider=pebblehost and upload it before pressing Start. Follow the complete guide at https://docs.helyx.gg/self-host/providers/pebblehost/. PebbleHost's application console cannot run npm installation commands or the terminal Env Builder.\n\n" +
159
+ rendered;
160
+ }
149
161
  if (databaseEngine === "mysql") {
150
162
  rendered = rendered
151
163
  .replace("3. Edit `.env`, replace every placeholder, set `DATABASE_ENGINE` to `postgresql` or `mysql`, and set the matching `DATABASE_URL`. Omitting `DATABASE_ENGINE` selects PostgreSQL.", "3. Edit `.env`, replace every placeholder, keep `DATABASE_ENGINE=mysql` and `HELYX_SHARD_MODE=off`, and set `DATABASE_URL` to the MySQL-compatible URL supplied by your provider. Percent-encode special characters in URL credentials.")
152
164
  .replace("Use this path when Docker with Compose support is available. The included configuration runs both Helyx and PostgreSQL 17.", "Use this path when Docker with Compose support is available. The included configuration runs only Helyx; it connects to your existing MySQL-compatible database and does not create a second database service.")
153
- .replace("2. Edit `.env` and replace all placeholder credentials and passwords.\n3. Keep `DATABASE_ENGINE=postgresql` with the supplied internal `DATABASE_URL` and `DATABASE_SSL=disable`.\n4. Run `docker compose up --build -d`.\n5. Review startup with `docker compose logs -f bot`.\n6. Stop services with `docker compose down`. Do not add `--volumes` unless you deliberately intend to delete the database volume.", "2. Edit `.env` and replace all placeholder credentials. Keep `DATABASE_ENGINE=mysql` and supply your provider's reachable `DATABASE_URL` and required `DATABASE_SSL` setting.\n3. Run `docker compose up --build -d`.\n4. Review startup with `docker compose logs -f bot`.\n5. Stop the bot with `docker compose down`. Your external database is not managed by this Compose file.");
165
+ .replace("1. Run `npm ci`, then `npm run envbuilder`. Choose **bundled** Docker PostgreSQL when asked for the database source. The wizard adds only the Docker credentials needed for that choice and creates the encryption key.\n2. Enter your Discord credentials and server ID, then save `.env`. The bot itself will run in Docker.\n3. Keep `DATABASE_ENGINE=postgresql` with the supplied internal `DATABASE_URL` and `DATABASE_SSL=disable`.\n4. Run `docker compose up --build -d`.\n5. Review startup with `docker compose logs -f bot`.\n6. Stop services with `docker compose down`. Do not add `--volumes` unless you deliberately intend to delete the database volume.", "1. Run `npm ci`, then `npm run envbuilder` to enter your Discord and external MySQL connection details and save `.env`.\n2. Keep `DATABASE_ENGINE=mysql` and supply your provider's reachable `DATABASE_URL` and required `DATABASE_SSL` setting.\n3. Run `docker compose up --build -d`.\n4. Review startup with `docker compose logs -f bot`.\n5. Stop the bot with `docker compose down`. Your external database is not managed by this Compose file.");
154
166
  }
155
167
  return rendered;
156
168
  }
@@ -174,20 +186,26 @@ function normalizeCrLf(value) {
174
186
  .replaceAll("\r", "\n")
175
187
  .replaceAll("\n", "\r\n");
176
188
  }
177
- function validateEnvironmentTemplate(template, databaseEngine) {
189
+ function validateEnvironmentTemplate(template, databaseEngine, databaseSsl) {
178
190
  const lines = new Set(template
179
191
  .replaceAll("\r\n", "\n")
180
192
  .split("\n")
181
193
  .map((line) => line.trim()));
182
194
  for (const [key, definition] of Object.entries(selfHostEnvironmentContract)) {
195
+ if ("omit" in definition) {
196
+ const setting = new RegExp(`^(?:#\\s*)?${key}\\s*=`, "u");
197
+ if ([...lines].some((line) => setting.test(line)))
198
+ throw new Error(`Self-host environment template contains an omitted setting: ${key}`);
199
+ continue;
200
+ }
183
201
  const value = key === "DATABASE_ENGINE"
184
202
  ? databaseEngine
185
203
  : key === "DATABASE_URL" && databaseEngine === "mysql"
186
204
  ? "mysql://user:password@host:3306/helyx"
187
- : key === "DATABASE_SSL" && databaseEngine === "mysql"
188
- ? "require"
205
+ : key === "DATABASE_SSL"
206
+ ? databaseSsl
189
207
  : definition.value;
190
- const expected = `${definition.optional ? "# " : ""}${key}=${value}`;
208
+ const expected = `${definition.optional ? "# " : ""}${key}='${value}'`;
191
209
  if (!lines.has(expected)) {
192
210
  throw new Error(`Self-host environment template does not match the bot contract: ${key}`);
193
211
  }
@@ -327,146 +345,67 @@ export const selfHostEnvironmentContract = {
327
345
  optional: false,
328
346
  },
329
347
  DATABASE_SSL: { value: "disable", optional: false },
330
- HELYX_DATABASE_STARTUP_MODE: { value: "apply", optional: true },
348
+ HELYX_DATABASE_STARTUP_MODE: { omit: true },
331
349
  HELYX_RUNTIME_PROFILE: { value: "self-hosted", optional: false },
332
350
  HELYX_DEPLOYMENT_ENVIRONMENT: { value: "self-hosted", optional: false },
333
351
  DISCORD_TOKEN: { value: "replace-me", optional: false },
334
352
  DISCORD_CLIENT_ID: { value: "12345678901234567", optional: false },
335
353
  HELYX_MESSAGE_CONTENT_ENABLED: { value: "false", optional: false },
336
- HELYX_AUTOMOD_EMBEDDED_WORKERS: { value: "1", optional: false },
337
- HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS: { value: "5000", optional: true },
338
- HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES: { value: "67108864", optional: true },
339
- HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD: {
340
- value: "100",
341
- optional: true,
342
- },
343
- HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS: {
344
- value: "300",
345
- optional: true,
346
- },
347
- DISCORD_INTERNAL_GUILD_ID: {
348
- value: "12345678901234567",
349
- optional: true,
350
- },
351
- APPROVED_ROLES: {
352
- value: "12345678901234567,12345678901234568",
353
- optional: true,
354
- },
355
- HELYX_MODULE_PACKAGES: {
356
- value: "",
357
- optional: true,
358
- },
354
+ // Advanced self-host options use runtime defaults; internal/hosted settings
355
+ // are deliberately absent from the customer example.
356
+ HELYX_AUTOMOD_EMBEDDED_WORKERS: { omit: true },
357
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS: { omit: true },
358
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES: { omit: true },
359
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD: { omit: true },
360
+ HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS: { omit: true },
361
+ DISCORD_INTERNAL_GUILD_ID: { omit: true },
362
+ APPROVED_ROLES: { omit: true },
363
+ HELYX_MODULE_PACKAGES: { omit: true },
359
364
  HELYX_MODULE_CATALOGUE_PATH: {
360
365
  value: "./helyx.modules.json",
361
366
  optional: false,
362
367
  },
363
- HELYX_DASHBOARD_PUBLIC_URL: {
364
- value: "https://app.helyx.gg",
365
- optional: true,
366
- },
367
- HELYX_BOT_CONTROL_SECRET: {
368
- value: "replace-with-a-base64-encoded-32-byte-key",
369
- optional: true,
370
- },
371
- HELYX_BOT_CONTROL_PORT: { value: "3001", optional: true },
372
- HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: {
373
- value: "900",
374
- optional: true,
375
- },
376
- HELYX_HOSTED_MODERATION_ENABLED: { value: "false", optional: false },
377
- HELYX_GUILD_COMMAND_WRITES_ENABLED: { value: "false", optional: false },
368
+ HELYX_DASHBOARD_PUBLIC_URL: { omit: true },
369
+ HELYX_BOT_CONTROL_SECRET: { omit: true },
370
+ HELYX_BOT_CONTROL_PORT: { omit: true },
371
+ HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: { omit: true },
372
+ HELYX_HOSTED_MODERATION_ENABLED: { omit: true },
373
+ HELYX_GUILD_COMMAND_WRITES_ENABLED: { value: "true", optional: false },
378
374
  HELYX_GUILD_COMMAND_WRITE_SCOPE: { value: "allowlist", optional: false },
379
- HELYX_GUILD_COMMAND_RELEASE_ID: { value: "", optional: true },
380
- HELYX_GLOBAL_COMMAND_MODE: { value: "legacy", optional: false },
381
- HELYX_GUILD_COMMAND_WRITE_GUILD_IDS: { value: "", optional: true },
375
+ HELYX_GUILD_COMMAND_RELEASE_ID: { omit: true },
376
+ HELYX_GLOBAL_COMMAND_MODE: { value: "platform-only", optional: false },
377
+ HELYX_GUILD_COMMAND_WRITE_GUILD_IDS: { value: "", optional: false },
382
378
  HELYX_GUILD_MEMBERS_ENABLED: { value: "false", optional: false },
383
379
  HELYX_CLOUD_CONNECT_ENABLED: { value: "true", optional: false },
384
- HELYX_CLOUD_ENROLMENT_TOKEN: {
385
- value: "replace-with-a-short-lived-enrolment-code",
386
- optional: true,
387
- },
388
- HELYX_CLOUD_ENROLMENT_FILE: {
389
- value: "./helyx.cloud-enrolment.json",
390
- optional: true,
391
- },
380
+ HELYX_CLOUD_ENROLMENT_TOKEN: { omit: true },
381
+ HELYX_CLOUD_ENROLMENT_FILE: { omit: true },
392
382
  HELYX_DATA_ENCRYPTION_KEY: {
393
383
  value: "replace-with-a-base64-encoded-32-byte-key",
394
- optional: true,
395
- },
396
- HELYX_CLOUD_API_URL: {
397
- value: "https://api.helyx.gg",
398
- optional: true,
399
- },
400
- HELYX_OAUTH_ENABLED: { value: "false", optional: false },
401
- HELYX_OAUTH_INTERNAL_SECRET: {
402
- value: "replace-with-a-base64-encoded-32-byte-key",
403
- optional: true,
404
- },
405
- HELYX_CLOUD_ENDPOINT: {
406
- value: "wss://connect.helyx.gg/v1/connect",
407
- optional: true,
408
- },
409
- HELYX_OWNER_USER_IDS: {
410
- value: "12345678901234567",
411
- optional: true,
384
+ optional: false,
412
385
  },
386
+ HELYX_CLOUD_API_URL: { omit: true },
387
+ HELYX_OAUTH_ENABLED: { omit: true },
388
+ HELYX_OAUTH_INTERNAL_SECRET: { omit: true },
389
+ HELYX_CLOUD_ENDPOINT: { omit: true },
390
+ HELYX_OWNER_USER_IDS: { omit: true },
413
391
  HELYX_SHARD_MODE: { value: "off", optional: false },
414
- HELYX_SHARD_COUNT: { value: "2", optional: true },
415
- HELYX_SHARD_SCOPE: { value: "local", optional: true },
416
- HELYX_SHARD_WORKERS_PER_REPLICA: { value: "2", optional: true },
417
- HELYX_SHARD_REDIS_URL: {
418
- value: "rediss://private-redis.example:6379",
419
- optional: true,
420
- },
421
- HELYX_SHARD_REDIS_NAMESPACE: {
422
- value: "helyx.self-host",
423
- optional: true,
424
- },
425
- HELYX_SHARD_GENERATION: {
426
- value: "local-20260728",
427
- optional: true,
428
- },
429
- HELYX_SHARD_DATABASE_CONNECTION_BUDGET: {
430
- value: "10",
431
- optional: true,
432
- },
433
- HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: {
434
- value: "2",
435
- optional: true,
436
- },
437
- HELYX_SHARD_WORKER_DATABASE_POOL_MAX: {
438
- value: "4",
439
- optional: true,
440
- },
441
- HELYX_CLOUD_AUDIT_RETENTION_DAYS: {
442
- value: "90",
443
- optional: true,
444
- },
445
- HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: {
446
- value: "365",
447
- optional: true,
448
- },
449
- HELYX_STATUS_ENABLED: { value: "false", optional: false },
450
- HELYX_STATUS_URL: {
451
- value: "https://status.helyx.gg",
452
- optional: true,
453
- },
454
- HELYX_STATUS_HEARTBEAT_SECRET: {
455
- value: "replace-with-a-base64-encoded-32-byte-key",
456
- optional: true,
457
- },
458
- HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS: {
459
- value: "60",
460
- optional: true,
461
- },
462
- CF_ACCESS_CLIENT_ID: {
463
- value: "replace-with-access-client-id",
464
- optional: true,
465
- },
466
- CF_ACCESS_CLIENT_SECRET: {
467
- value: "replace-with-access-client-secret",
468
- optional: true,
469
- },
392
+ HELYX_SHARD_COUNT: { omit: true },
393
+ HELYX_SHARD_SCOPE: { omit: true },
394
+ HELYX_SHARD_WORKERS_PER_REPLICA: { omit: true },
395
+ HELYX_SHARD_REDIS_URL: { omit: true },
396
+ HELYX_SHARD_REDIS_NAMESPACE: { omit: true },
397
+ HELYX_SHARD_GENERATION: { omit: true },
398
+ HELYX_SHARD_DATABASE_CONNECTION_BUDGET: { omit: true },
399
+ HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: { omit: true },
400
+ HELYX_SHARD_WORKER_DATABASE_POOL_MAX: { omit: true },
401
+ HELYX_CLOUD_AUDIT_RETENTION_DAYS: { omit: true },
402
+ HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: { omit: true },
403
+ HELYX_STATUS_ENABLED: { omit: true },
404
+ HELYX_STATUS_URL: { omit: true },
405
+ HELYX_STATUS_HEARTBEAT_SECRET: { omit: true },
406
+ HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS: { omit: true },
407
+ CF_ACCESS_CLIENT_ID: { omit: true },
408
+ CF_ACCESS_CLIENT_SECRET: { omit: true },
470
409
  LOG_LEVEL: { value: "info", optional: false },
471
410
  NODE_ENV: { value: "production", optional: false },
472
411
  };
package/dist/setup.js CHANGED
@@ -40,15 +40,14 @@ async function ensureDataEncryptionKey(environmentPath, createKey) {
40
40
  const content = await readFile(environmentPath, "utf8");
41
41
  const activeKeyPattern = new RegExp(`^[\\t ]*${dataEncryptionKeyName}[\\t ]*=[\\t ]*([^\\r\\n]*?)[\\t ]*(?=\\r?$)`, "mu");
42
42
  const activeKey = activeKeyPattern.exec(content);
43
- if (activeKey &&
44
- activeKey[1] !== undefined &&
45
- activeKey[1] !== "" &&
46
- activeKey[1] !== dataEncryptionKeyPlaceholder) {
43
+ const currentKey = parseEnv(content)[dataEncryptionKeyName];
44
+ if (currentKey && currentKey !== dataEncryptionKeyPlaceholder) {
47
45
  await chmod(environmentPath, 0o600);
48
46
  return false;
49
47
  }
50
48
  const key = createKey();
51
- const replacement = `${dataEncryptionKeyName}=${key}`;
49
+ const quoted = activeKey?.[1]?.startsWith("'") || activeKey?.[1]?.startsWith('"');
50
+ const replacement = `${dataEncryptionKeyName}=${quoted ? `'${key}'` : key}`;
52
51
  let updated;
53
52
  if (activeKey) {
54
53
  updated = content.replace(activeKeyPattern, replacement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Command-line tools for running and generating self-hosted Helyx deployments.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -52,7 +52,7 @@
52
52
  "build": "tsc -b"
53
53
  },
54
54
  "dependencies": {
55
- "@helyx/bot": ">=1.5.2 <2.0.0",
55
+ "@helyx/bot": ">=1.6.0 <2.0.0",
56
56
  "@helyx/config": ">=1.6.0 <2.0.0",
57
57
  "@helyx/database": ">=2.5.0 <3.0.0",
58
58
  "semver": "7.8.5",
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "catalogueVersion": 86,
3
+ "catalogueVersion": 88,
4
4
  "core": {
5
5
  "package": "@helyx/bot",
6
- "version": "1.5.3",
6
+ "version": "1.6.0",
7
7
  "availability": "published"
8
8
  },
9
9
  "generator": {
10
10
  "package": "@helyx/cli",
11
- "version": "1.6.0",
11
+ "version": "1.7.0",
12
12
  "command": "helyx",
13
13
  "availability": "published"
14
14
  },
@@ -1,134 +1,33 @@
1
- # REQUIRED — EDIT THESE VALUES
1
+ # Helyx self-hosted environment
2
+ # Keep this file private. Retain the encryption key when updating.
2
3
 
3
- HELYX_RUNTIME_PROFILE=self-hosted
4
- HELYX_DEPLOYMENT_ENVIRONMENT=self-hosted
4
+ # Runtime
5
+ HELYX_RUNTIME_PROFILE='self-hosted'
6
+ HELYX_DEPLOYMENT_ENVIRONMENT='self-hosted'
7
+ NODE_ENV='production'
5
8
 
6
9
  # Discord
7
- DISCORD_TOKEN=replace-me
8
- DISCORD_CLIENT_ID=12345678901234567
9
- # Explicit privileged gateway opt-in. Keep false unless a reviewed loaded module
10
- # requires message content and it is enabled in the Discord developer portal.
11
- HELYX_MESSAGE_CONTENT_ENABLED=false
12
- # Enhanced Auto Moderation stays inside this bot process. One worker is the
13
- # recommended default (range 1-4); increase only after measuring CPU and memory.
14
- # Hosted scanning uses the separate Helyx Ecko service, not these settings.
15
- HELYX_AUTOMOD_EMBEDDED_WORKERS=1
16
- # Optional conservative queue overrides (hard ceilings: 5000 items, 64 MiB,
17
- # 100 items per server). Defaults are used when these remain commented.
18
- # HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS=5000
19
- # HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES=67108864
20
- # HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD=100
21
- # HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS=300
22
- # Enable for loaded member workflows such as Welcome, Access Control or Levels,
23
- # with Server Members Intent also enabled in the Discord developer portal.
24
- HELYX_GUILD_MEMBERS_ENABLED=false
25
- # Database
26
- # Docker: keep the URL. Direct Node.js/managed hosting: use your provider URL.
27
- DATABASE_ENGINE=postgresql
28
- DATABASE_URL=postgresql://database:5432/helyx
29
- DATABASE_SSL=disable
30
- # Self-host startup applies pending migrations automatically (the default).
31
- # Hosted-only inspect mode is not a self-host setting; leave this commented.
32
- # HELYX_DATABASE_STARTUP_MODE=apply
33
- # Match your provider: disable=no TLS; require=TLS without certificate
34
- # verification; verify-full=TLS with certificate verification.
35
-
36
- # Included Docker database — both password values must match.
37
- PGUSER=helyx
38
- PGPASSWORD=replace-with-a-strong-password
39
- POSTGRES_DB=helyx
40
- POSTGRES_USER=helyx
41
- POSTGRES_PASSWORD=replace-with-a-strong-password
42
-
43
- # OPTIONAL EVERYDAY SETTINGS
44
-
45
- # Ready for dashboard linking, but remains unpaired until you use a one-use code.
46
- HELYX_CLOUD_CONNECT_ENABLED=true
47
- # Public dashboard origin used in bot-generated report links.
48
- # HELYX_DASHBOARD_PUBLIC_URL=https://app.helyx.gg
49
- # HELYX_OWNER_USER_IDS=12345678901234567
50
-
51
- # trace, debug, info, warn, error, fatal or silent
52
- LOG_LEVEL=info
53
-
54
- # Guild-scoped development commands only
55
- # DISCORD_INTERNAL_GUILD_ID=12345678901234567
56
- # APPROVED_ROLES=12345678901234567,12345678901234568
57
-
58
- # SHARDING: OFF BY DEFAULT
59
-
60
- # `off` is the compatible self-host default. Keep shard-only values commented
61
- # in this mode; an empty value is still rejected as configured. MySQL must stay
62
- # off. Advanced PostgreSQL self-hosting may use `auto` under one
63
- # supervisor, or `fixed` with a deliberate total count. Local mode never needs
64
- # Redis. Coordinated multi-host self-hosting is outside the first release.
65
- HELYX_SHARD_MODE=off
66
- # HELYX_SHARD_SCOPE=local
67
- # HELYX_SHARD_COUNT=2
68
- # HELYX_SHARD_WORKERS_PER_REPLICA=2
69
- # HELYX_SHARD_REDIS_URL=rediss://private-redis.example:6379
70
- # HELYX_SHARD_REDIS_NAMESPACE=helyx.self-host
71
- # HELYX_SHARD_GENERATION=local-20260728
72
- # HELYX_SHARD_DATABASE_CONNECTION_BUDGET=10
73
- # HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX=2
74
- # HELYX_SHARD_WORKER_DATABASE_POOL_MAX=4
75
- # Redis URL and namespace are intentionally unsupported for the first
76
- # self-hosted release: do not uncomment them for local auto or fixed mode.
77
-
78
- # ADVANCED INTEGRATIONS — EDIT ONLY WHEN NEEDED
79
-
80
- # Additional reviewed module packages
81
- # HELYX_MODULE_PACKAGES=
82
-
83
- # Private dashboard control endpoint — configure both together
84
- # HELYX_BOT_CONTROL_SECRET=replace-with-a-base64-encoded-32-byte-key
85
- # HELYX_BOT_CONTROL_PORT=3001
86
- # HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS=900
87
- HELYX_HOSTED_MODERATION_ENABLED=false
10
+ DISCORD_CLIENT_ID='12345678901234567'
11
+ DISCORD_TOKEN='replace-me'
12
+ HELYX_GUILD_MEMBERS_ENABLED='false'
13
+ HELYX_MESSAGE_CONTENT_ENABLED='false'
88
14
 
89
- # Guild command writes remain off until explicitly enabled for known guild IDs.
90
- # For enabled-module server commands, set writes=true, retain allowlist scope,
91
- # add comma-separated Discord server IDs below, and use global mode platform-only.
92
- # See README.md before changing these defaults. Legacy mode still allows globals.
93
- HELYX_GUILD_COMMAND_WRITES_ENABLED=false
94
- HELYX_GUILD_COMMAND_WRITE_SCOPE=allowlist
95
- # HELYX_GUILD_COMMAND_RELEASE_ID=
96
- HELYX_GLOBAL_COMMAND_MODE=legacy
97
- # HELYX_GUILD_COMMAND_WRITE_GUILD_IDS=
98
-
99
- # Alternative Cloud Connect enrolment — the Discord command is preferred
100
- # HELYX_CLOUD_ENROLMENT_TOKEN=replace-with-a-short-lived-enrolment-code
101
- # HELYX_CLOUD_ENROLMENT_FILE=./helyx.cloud-enrolment.json
102
-
103
- # Hosted OAuth bridge. Keep disabled unless the API and bot share this secret.
104
- HELYX_OAUTH_ENABLED=false
105
- # HELYX_OAUTH_INTERNAL_SECRET=replace-with-a-base64-encoded-32-byte-key
106
-
107
- # DANGER ZONE — HELYX-MANAGED VALUES, DO NOT EDIT
108
-
109
- # Generated module selection
110
- HELYX_MODULE_CATALOGUE_PATH=./helyx.modules.json
111
-
112
- # Generated privately by npm run setup. Required by Cloud Connect, Giveaways,
113
- # and Invite Tracking; retain it unchanged across restarts and deployments.
114
- # HELYX_DATA_ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
115
-
116
- # Official Cloud Connect endpoints
117
- # HELYX_CLOUD_API_URL=https://api.helyx.gg
118
- # HELYX_CLOUD_ENDPOINT=wss://connect.helyx.gg/v1/connect
119
-
120
- # Security audit retention
121
- # HELYX_CLOUD_AUDIT_RETENTION_DAYS=90
122
- # HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS=365
123
-
124
- # Optional public status reporting. Keep disabled unless this installation has
125
- # its own approved Helyx status credentials.
126
- HELYX_STATUS_ENABLED=false
127
- # HELYX_STATUS_URL=https://status.helyx.gg
128
- # HELYX_STATUS_HEARTBEAT_SECRET=replace-with-a-base64-encoded-32-byte-key
129
- # HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS=60
130
- # CF_ACCESS_CLIENT_ID=replace-with-access-client-id
131
- # CF_ACCESS_CLIENT_SECRET=replace-with-access-client-secret
132
-
133
- # Runtime mode
134
- NODE_ENV=production
15
+ # Database
16
+ DATABASE_ENGINE='postgresql'
17
+ DATABASE_URL='postgresql://database:5432/helyx'
18
+ DATABASE_SSL='disable'
19
+
20
+ # Commands
21
+ HELYX_GUILD_COMMAND_WRITES_ENABLED='true'
22
+ HELYX_GUILD_COMMAND_WRITE_SCOPE='allowlist'
23
+ HELYX_GLOBAL_COMMAND_MODE='platform-only'
24
+ HELYX_GUILD_COMMAND_WRITE_GUILD_IDS=''
25
+
26
+ # Modules and dashboard
27
+ HELYX_MODULE_CATALOGUE_PATH='./helyx.modules.json'
28
+ HELYX_CLOUD_CONNECT_ENABLED='true'
29
+ HELYX_SHARD_MODE='off'
30
+ LOG_LEVEL='info'
31
+
32
+ # Keep this key unchanged across restarts and updates
33
+ HELYX_DATA_ENCRYPTION_KEY='replace-with-a-base64-encoded-32-byte-key'
@@ -76,8 +76,8 @@ Complete setup and credentials first. Before the start command below, run the
76
76
  container doctor preflight that follows the numbered steps; a host-shell doctor
77
77
  cannot normally resolve a container-only hostname such as `database`.
78
78
 
79
- 1. Run `npm ci`, then `npm run setup`. This creates the local configuration and data-encryption key safely; the bot itself will still run in Docker.
80
- 2. Edit `.env` and replace all placeholder credentials and passwords.
79
+ 1. Run `npm ci`, then `npm run envbuilder`. Choose **bundled** Docker PostgreSQL when asked for the database source. The wizard adds only the Docker credentials needed for that choice and creates the encryption key.
80
+ 2. Enter your Discord credentials and server ID, then save `.env`. The bot itself will run in Docker.
81
81
  3. Keep `DATABASE_ENGINE=postgresql` with the supplied internal `DATABASE_URL` and `DATABASE_SSL=disable`.
82
82
  4. Run `docker compose up --build -d`.
83
83
  5. Review startup with `docker compose logs -f bot`.
@@ -151,10 +151,10 @@ Command registration is global or guild-scoped according to each reviewed comman
151
151
 
152
152
  ### Server-scoped module commands
153
153
 
154
- The template keeps guild command writes disabled and uses
155
- `HELYX_GLOBAL_COMMAND_MODE=legacy`. This does not disable all commands: core and
156
- compatible legacy commands can still register globally. However, server-scoped
157
- module command builds cannot be applied while guild writes are disabled.
154
+ The compact template enables guild command writes with allowlist scope and
155
+ `HELYX_GLOBAL_COMMAND_MODE=platform-only`. Enter your Discord server IDs before
156
+ starting. Core commands remain global; feature commands are built for the
157
+ enabled modules in each approved server.
158
158
 
159
159
  To use enabled-module command builds in servers you explicitly approve, set:
160
160
 
@@ -120,6 +120,12 @@ export function validateDatabaseUrl(value, engine) {
120
120
  }
121
121
  }
122
122
 
123
+ export function normalizeDatabaseUrl(value, engine) {
124
+ return engine === "mysql"
125
+ ? value.replace(/^jdbc:(?=mysql:\/\/)/iu, "")
126
+ : value;
127
+ }
128
+
123
129
  // Use single quotes to retain #, $, spaces and backslashes literally in Node
124
130
  // and Compose. Unusual mixed-quote values require manual editing, not guessing.
125
131
  export function encodeValue(value) {
@@ -188,113 +194,148 @@ export function updateEnvironment(content, changes) {
188
194
 
189
195
  const environmentGroups = [
190
196
  [
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,
197
+ "Runtime",
198
+ ["HELYX_RUNTIME_PROFILE", "HELYX_DEPLOYMENT_ENVIRONMENT", "NODE_ENV"],
198
199
  ],
199
200
  [
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,
201
+ "Discord",
202
+ [
203
+ "DISCORD_CLIENT_ID",
204
+ "DISCORD_TOKEN",
205
+ "HELYX_GUILD_MEMBERS_ENABLED",
206
+ "HELYX_MESSAGE_CONTENT_ENABLED",
207
+ ],
208
208
  ],
209
- ["DATA ENCRYPTION - KEEP THIS KEY", /^HELYX_DATA_ENCRYPTION_KEY$/u],
210
- ["LOGGING", /^LOG_LEVEL$/u],
211
- ["AUTO MODERATION", /^HELYX_AUTOMOD_/u],
212
- ["SHARDING - ADVANCED", /^HELYX_SHARD_/u],
213
- ["MODULE SELECTION", /^HELYX_MODULE_/u],
209
+ ["Database", ["DATABASE_ENGINE", "DATABASE_URL", "DATABASE_SSL"]],
214
210
  [
215
- "PRIVATE CONTROL - ADVANCED",
216
- /^HELYX_BOT_CONTROL_|^HELYX_HOSTED_MODERATION_ENABLED$|^HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS$/u,
211
+ "Commands",
212
+ [
213
+ "HELYX_GUILD_COMMAND_WRITES_ENABLED",
214
+ "HELYX_GUILD_COMMAND_WRITE_SCOPE",
215
+ "HELYX_GLOBAL_COMMAND_MODE",
216
+ "HELYX_GUILD_COMMAND_WRITE_GUILD_IDS",
217
+ ],
217
218
  ],
218
- ["CLOUD CONNECT - ADVANCED", /^HELYX_CLOUD_/u],
219
- ["OAUTH - ADVANCED", /^HELYX_OAUTH_/u],
220
- ["STATUS REPORTING - OPTIONAL", /^HELYX_STATUS_|^CF_ACCESS_/u],
221
219
  [
222
- "INTERNAL DEVELOPMENT - OPTIONAL",
223
- /^(?:DISCORD_INTERNAL_GUILD_ID|APPROVED_ROLES)$/u,
220
+ "Modules and dashboard",
221
+ [
222
+ "HELYX_MODULE_CATALOGUE_PATH",
223
+ "HELYX_CLOUD_CONNECT_ENABLED",
224
+ "HELYX_SHARD_MODE",
225
+ "LOG_LEVEL",
226
+ ],
224
227
  ],
225
- ["CUSTOM SETTINGS", /./u],
228
+ ["Keep this key unchanged across restarts and updates", [encryptionKey]],
226
229
  ];
230
+ const standardKeys = new Set(environmentGroups.flatMap(([, keys]) => keys));
227
231
  const oldHeadings = new Set([
228
- "REQUIRED — EDIT THESE VALUES",
229
- "Discord",
230
- "Database",
232
+ "INSTALLATION",
233
+ "DISCORD BOT",
234
+ "DISCORD SERVER COMMANDS",
235
+ "DISCORD GATEWAY INTENTS",
236
+ "DATABASE CONNECTION",
237
+ "BUNDLED DOCKER POSTGRESQL",
238
+ "DASHBOARD AND OWNERS",
239
+ "DATA ENCRYPTION - KEEP THIS KEY",
240
+ "LOGGING",
241
+ "SHARDING - ADVANCED",
242
+ "MODULE SELECTION",
243
+ "CUSTOM SETTINGS",
244
+ "AUTO MODERATION",
245
+ "PRIVATE CONTROL - ADVANCED",
246
+ "CLOUD CONNECT - ADVANCED",
247
+ "OAUTH - ADVANCED",
248
+ "STATUS REPORTING - OPTIONAL",
249
+ "INTERNAL DEVELOPMENT - OPTIONAL",
250
+ "REQUIRED ? EDIT THESE VALUES",
231
251
  "OPTIONAL EVERYDAY SETTINGS",
232
252
  "SHARDING: OFF BY DEFAULT",
233
- "ADVANCED INTEGRATIONS — EDIT ONLY WHEN NEEDED",
234
- "DANGER ZONE — HELYX-MANAGED VALUES, DO NOT EDIT",
253
+ "ADVANCED INTEGRATIONS ? EDIT ONLY WHEN NEEDED",
254
+ "DANGER ZONE ? HELYX-MANAGED VALUES, DO NOT EDIT",
235
255
  "Runtime mode",
236
256
  "Helyx self-hosted environment",
237
257
  "Keep this file private. Lines starting with # are comments.",
258
+ "Keep this file private. Retain the encryption key when updating.",
259
+ "Bundled Docker database",
260
+ "Custom settings",
261
+ ...environmentGroups.map(([title]) => title),
238
262
  ]);
239
263
 
240
- export function formatEnvironment(content) {
264
+ export function formatEnvironment(content, { template = false } = {}) {
241
265
  const newline = content.includes("\r\n") ? "\r\n" : "\n";
242
- const groups = environmentGroups.map(() => []);
266
+ const sections = [
267
+ ...environmentGroups,
268
+ ["Bundled Docker database", []],
269
+ ["Custom settings", []],
270
+ ];
271
+ const groups = sections.map(() => []);
243
272
  let pending = [];
244
273
  let previousGroup;
245
- const append = (index, lines) => {
246
- const text = lines.join("\n").trim();
247
- if (!text) return;
248
- if (
249
- groups[index].length &&
250
- text.startsWith("#") &&
251
- !/^#\s*[A-Za-z_][A-Za-z0-9_]*\s*=/u.test(text)
252
- )
253
- groups[index].push("");
254
- groups[index].push(text);
255
- };
256
274
  for (const record of records(content)) {
257
275
  const text = record.text.replace(/\r\n/gu, "\n").replace(/\n$/u, "");
258
276
  const heading = text.match(/^#\s*(.*?)\s*$/u)?.[1];
259
277
  if (
260
278
  heading !== undefined &&
261
- (oldHeadings.has(heading) ||
262
- /^=+$/u.test(heading) ||
263
- environmentGroups.some(([title]) => heading === title))
279
+ (template || oldHeadings.has(heading) || /^=+$/u.test(heading))
264
280
  )
265
281
  continue;
266
282
  const key =
267
283
  record.key ?? text.match(/^\s*#\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/u)?.[1];
284
+ if (key && (!record.key || (template && !standardKeys.has(key)))) {
285
+ pending = [];
286
+ continue;
287
+ }
268
288
  if (!key) {
269
289
  if (!text.trim() && pending.length && previousGroup !== undefined) {
270
- append(previousGroup, pending);
290
+ groups[previousGroup].push({ key: "", text: pending.join("\n") });
271
291
  pending = [];
272
- } else if (text.trim()) pending.push(text);
292
+ } else if (text.trim() && !template) pending.push(text);
273
293
  continue;
274
294
  }
275
- const group = environmentGroups.findIndex(([, pattern]) =>
276
- pattern.test(key),
295
+ const standardGroup = environmentGroups.findIndex(([, keys]) =>
296
+ keys.includes(key),
277
297
  );
278
- append(group, [...pending, text]);
298
+ const group =
299
+ standardGroup >= 0
300
+ ? standardGroup
301
+ : /^(?:PGUSER|PGPASSWORD|POSTGRES_)/u.test(key)
302
+ ? environmentGroups.length
303
+ : environmentGroups.length + 1;
304
+ // Standard fields use the same compact quoting as the website. Keep custom
305
+ // records verbatim, including multiline values and their comments.
306
+ const assignment =
307
+ standardGroup >= 0
308
+ ? `${key}=${encodeValue(parseEnv(record.text)[key])}${record.comment}`
309
+ : text;
310
+ groups[group].push({ key, text: [...pending, assignment].join("\n") });
279
311
  pending = [];
280
312
  previousGroup = group;
281
313
  }
282
- if (pending.length) append(previousGroup ?? groups.length - 1, pending);
283
- const divider =
284
- "# ============================================================================";
285
- const output =
314
+ if (pending.length)
315
+ groups[previousGroup ?? groups.length - 1].push({
316
+ key: "",
317
+ text: pending.join("\n"),
318
+ });
319
+ const formatted = (
286
320
  [
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);
321
+ "# Helyx self-hosted environment\n# Keep this file private. Retain the encryption key when updating.",
322
+ ...groups.flatMap((lines, index) => {
323
+ if (!lines.length) return [];
324
+ const keys = sections[index][1];
325
+ if (keys.length)
326
+ lines.sort(
327
+ (left, right) => keys.indexOf(left.key) - keys.indexOf(right.key),
328
+ );
329
+ return [
330
+ `# ${sections[index][0]}\n${lines.map(({ text }) => text).join("\n")}`,
331
+ ];
332
+ }),
333
+ ].join("\n\n") + "\n"
334
+ ).replace(/\n/gu, newline);
297
335
  const before = parseEnv(content);
336
+ if (template)
337
+ for (const key of Object.keys(before))
338
+ if (!standardKeys.has(key)) delete before[key];
298
339
  const after = parseEnv(formatted);
299
340
  if (
300
341
  Object.keys(before).length !== Object.keys(after).length ||
@@ -420,7 +461,10 @@ export async function runWizard({
420
461
  "Choose a downloaded Helyx starter folder containing package.json and .env.example.",
421
462
  );
422
463
  const original = await optionalRead(join(directory, ".env"));
423
- const template = await readFile(join(directory, ".env.example"), "utf8");
464
+ const template = formatEnvironment(
465
+ await readFile(join(directory, ".env.example"), "utf8"),
466
+ { template: true },
467
+ );
424
468
  const existing = parseEnv(original ?? "");
425
469
  const defaults = parseEnv(template);
426
470
  const values = { ...defaults, ...existing };
@@ -437,6 +481,7 @@ export async function runWizard({
437
481
  validate = () => {},
438
482
  fallback,
439
483
  optional = false,
484
+ normalize = (value) => value,
440
485
  } = {},
441
486
  ) => {
442
487
  const current = fallback ?? values[key] ?? "";
@@ -450,8 +495,10 @@ export async function runWizard({
450
495
  `${label} [${display}]${optional ? " (Enter keeps; - clears)" : " (Enter keeps)"}: `,
451
496
  { secret: secret && !visibleInput },
452
497
  );
453
- const value = optional && answer === "-" ? "" : answer || current;
454
498
  try {
499
+ const value = normalize(
500
+ optional && answer === "-" ? "" : answer || current,
501
+ );
455
502
  if (placeholder.test(value))
456
503
  throw new WizardError(
457
504
  "Replace the example placeholder with your own value.",
@@ -460,7 +507,14 @@ export async function runWizard({
460
507
  encodeValue(value);
461
508
  validate(value);
462
509
  values[key] = value;
463
- if (value !== existing[key]) changes[key] = value;
510
+ if (
511
+ value !== existing[key] &&
512
+ (!optional ||
513
+ value ||
514
+ existing[key] !== undefined ||
515
+ standardKeys.has(key))
516
+ )
517
+ changes[key] = value;
464
518
  return value;
465
519
  } catch (error) {
466
520
  io.print(error.message);
@@ -484,8 +538,7 @@ export async function runWizard({
484
538
  const bundledAvailable =
485
539
  engine === "postgresql" &&
486
540
  defaults.DATABASE_ENGINE === "postgresql" &&
487
- (await optionalRead(join(directory, "compose.yaml"))) !== null &&
488
- Boolean(defaults.POSTGRES_PASSWORD);
541
+ (await optionalRead(join(directory, "compose.yaml"))) !== null;
489
542
  let databaseMode = "external";
490
543
  if (bundledAvailable) {
491
544
  for (;;) {
@@ -537,7 +590,11 @@ export async function runWizard({
537
590
  DATABASE_SSL: "disable",
538
591
  });
539
592
  } else {
540
- await askValue(
593
+ if (engine === "mysql")
594
+ io.print(
595
+ "Paste a MySQL URL or JDBC connection string. The jdbc: prefix is removed automatically; missing login details are requested next.",
596
+ );
597
+ const connection = await askValue(
541
598
  "DATABASE_URL",
542
599
  "Database connection URL (typing and paste visible)",
543
600
  {
@@ -549,8 +606,30 @@ export async function runWizard({
549
606
  ? ""
550
607
  : values.DATABASE_URL,
551
608
  validate: (value) => validateDatabaseUrl(value, engine),
609
+ normalize: (value) => normalizeDatabaseUrl(value, engine),
552
610
  },
553
611
  );
612
+ if (engine === "mysql") {
613
+ const url = new URL(connection);
614
+ for (const field of ["username", "password"]) {
615
+ while (!url[field]) {
616
+ const value = await io.ask(`Database ${field}: `, {
617
+ secret: field === "password",
618
+ });
619
+ if (!value || /[\p{Cc}\p{Cf}]/u.test(value)) {
620
+ io.print("Enter a single-line value.");
621
+ continue;
622
+ }
623
+ url[field] = encodeURIComponent(value).replace(
624
+ /[!'()*]/gu,
625
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
626
+ );
627
+ }
628
+ }
629
+ values.DATABASE_URL = url.href.replaceAll("'", "%27");
630
+ if (values.DATABASE_URL !== existing.DATABASE_URL)
631
+ changes.DATABASE_URL = values.DATABASE_URL;
632
+ }
554
633
  io.print(
555
634
  "SSL: disable = no TLS; require = TLS without certificate verification; verify-full = TLS with certificate verification. Use your provider's requirement.",
556
635
  );
@@ -681,6 +760,17 @@ export async function runWizard({
681
760
  HELYX_RUNTIME_PROFILE: "self-hosted",
682
761
  HELYX_DEPLOYMENT_ENVIRONMENT: "self-hosted",
683
762
  });
763
+ for (const [key, value] of Object.entries({
764
+ NODE_ENV: "production",
765
+ HELYX_MODULE_CATALOGUE_PATH: "./helyx.modules.json",
766
+ HELYX_SHARD_MODE: "off",
767
+ LOG_LEVEL: "info",
768
+ HELYX_GLOBAL_COMMAND_MODE: "platform-only",
769
+ HELYX_GUILD_COMMAND_WRITE_SCOPE: "allowlist",
770
+ })) {
771
+ if (existing[key] === undefined && changes[key] === undefined)
772
+ changes[key] = value;
773
+ }
684
774
  if (engine === "mysql") Object.assign(changes, { HELYX_SHARD_MODE: "off" });
685
775
  const updated = formatEnvironment(
686
776
  updateEnvironment(original ?? template, changes),