@helyx/cli 1.6.1 → 1.8.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add helyx help connect, status and disconnect for opt-in online Help, encrypted local credentials and credential revocation. Package the compatible bot and updated self-host catalogue while retaining offline Help without enrolment.
8
+
9
+ ## 1.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Add public `/proc` process information for hosted and self-hosted bots, replacing
14
+ the private `/dev proc` path. Keep the reply private and independent of database
15
+ availability.
16
+
17
+ Add a PebbleHost starter profile with direct Node CLI scripts, flat starter ZIPs,
18
+ compact quoted environment examples and matching guided environment builder
19
+ output. Preserve existing custom settings and encryption keys when rebuilding
20
+ an environment file, and recognize quoted placeholders during setup.
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies
25
+ - @helyx/bot@1.6.0
26
+
3
27
  ## 1.6.1
4
28
 
5
29
  ### Patch Changes
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { resolveDatabaseAdapter } from "@helyx/database/adapters";
7
7
  import { formatCommandError } from "./errors.js";
8
8
  import { runSelfHostCommand } from "./self-host/command.js";
9
9
  import { loadLocalEnvironment, setupSelfHostEnvironment } from "./setup.js";
10
+ import { runHelpCommand } from "./help-command.js";
10
11
  const command = process.argv[2];
11
12
  try {
12
13
  if (command === "start") {
@@ -31,6 +32,9 @@ try {
31
32
  else if (command === "self-host") {
32
33
  await runSelfHostCommand(process.argv.slice(3));
33
34
  }
35
+ else if (command === "help" && process.argv[3]) {
36
+ await runHelpCommand(process.argv.slice(3));
37
+ }
34
38
  else if (command === "help" || command === "--help" || command === "-h") {
35
39
  printHelp();
36
40
  }
@@ -149,6 +153,8 @@ Commands:
149
153
  start Start the Helyx Discord bot
150
154
  migrate Apply core migrations for the selected database engine and exit
151
155
  doctor Validate environment, database schema and module packages
156
+ help connect|status|disconnect [--project <path>] [--confirm]
157
+ Manage optional Help-only online access; local Help needs no account
152
158
  self-host init --manifest <path> [--output <path>] [--install]
153
159
  Generate a self-hosted starter project from a reviewed manifest
154
160
  self-host update --check [--project <path>]
@@ -0,0 +1,8 @@
1
+ export declare function runHelpCommand(args: readonly string[], options?: {
2
+ cwd?: string;
3
+ environment?: NodeJS.ProcessEnv;
4
+ question?: (prompt: string) => Promise<string>;
5
+ write?: (message: string) => void;
6
+ fetch?: typeof fetch;
7
+ }): Promise<void>;
8
+ //# sourceMappingURL=help-command.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { resolve } from "node:path";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { HelpCredentialStore, exchangeHelpEnrolment, revokeHelpCredential, } from "@helyx/bot";
4
+ import { loadLocalEnvironment } from "./setup.js";
5
+ export async function runHelpCommand(args, options = {}) {
6
+ const [action, ...rest] = args;
7
+ let directory = resolve(options.cwd ?? process.cwd()), confirm = false;
8
+ if (!["connect", "status", "disconnect"].includes(action ?? ""))
9
+ throw new Error("Use: helyx help connect|status|disconnect [--project <path>] [--confirm]");
10
+ for (let index = 0; index < rest.length; index++) {
11
+ if (rest[index] === "--project" && rest[index + 1])
12
+ directory = resolve(directory, rest[++index]);
13
+ else if (rest[index] === "--confirm")
14
+ confirm = true;
15
+ else
16
+ throw new Error("Unsupported Help option. Paste enrolment codes at the prompt, not in command arguments.");
17
+ }
18
+ const environment = { ...(options.environment ?? process.env) };
19
+ await loadLocalEnvironment(directory, environment);
20
+ if (!environment.HELYX_DATA_ENCRYPTION_KEY)
21
+ throw new Error("Set up the local HELYX_DATA_ENCRYPTION_KEY before managing online Help. Local Help remains available.");
22
+ if (action === "connect" &&
23
+ environment.HELYX_RUNTIME_PROFILE !== "self-hosted")
24
+ throw new Error("Online Help enrolment is for a self-hosted installation. Set HELYX_RUNTIME_PROFILE=self-hosted in that project's environment.");
25
+ const store = new HelpCredentialStore(directory, environment.HELYX_DATA_ENCRYPTION_KEY), write = options.write ?? console.log;
26
+ if (action === "status") {
27
+ const credential = await store.read();
28
+ write(!credential
29
+ ? "Help: local bundled FAQs. No online credential saved."
30
+ : `Help: ${Date.parse(credential.expiresAt) > Date.now() ? "online credential saved" : "credential expired; local FAQs remain available"}. Installation ${credential.installationId}. Expires ${credential.expiresAt}.`);
31
+ return;
32
+ }
33
+ if (action === "disconnect") {
34
+ const credential = await store.read();
35
+ let remote = true;
36
+ if (credential && Date.parse(credential.expiresAt) > Date.now())
37
+ try {
38
+ await revokeHelpCredential(credential.token, options.fetch);
39
+ }
40
+ catch {
41
+ remote = false;
42
+ }
43
+ await store.remove();
44
+ write(remote
45
+ ? "Online Help disconnected. Local bundled FAQs remain available."
46
+ : "Online Help disconnected locally. Revoke its credential from your dashboard when the service is available.");
47
+ return;
48
+ }
49
+ const reader = options.question
50
+ ? null
51
+ : createInterface({ input: process.stdin, output: process.stdout }), question = options.question ?? ((prompt) => reader.question(prompt));
52
+ try {
53
+ write("Optional online Help sends your public Helyx question to Helyx and its embedding provider. It does not connect guild settings or require Cloud Connect.");
54
+ if (!confirm &&
55
+ (await question("Type CONNECT to save an encrypted Help-only credential: ")).trim() !== "CONNECT") {
56
+ write("Cancelled. Local Help remains available.");
57
+ return;
58
+ }
59
+ const code = (await question("Paste the one-time Help code from your self-host dashboard: ")).trim();
60
+ const credential = await exchangeHelpEnrolment(code, options.fetch);
61
+ await store.save(credential);
62
+ write(`Online Help connected for installation ${credential.installationId}. Credential expires ${credential.expiresAt}.`);
63
+ }
64
+ catch {
65
+ throw new Error("Online Help could not be connected. Check the one-time code, service availability, local encryption key and write permissions. No credential value was printed.");
66
+ }
67
+ finally {
68
+ reader?.close();
69
+ }
70
+ }
71
+ //# sourceMappingURL=help-command.js.map
package/dist/index.d.ts CHANGED
@@ -6,4 +6,5 @@ export * from "./self-host/manifest.js";
6
6
  export * from "./self-host/project-generator.js";
7
7
  export * from "./self-host/update.js";
8
8
  export * from "./setup.js";
9
+ export * from "./help-command.js";
9
10
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@ export * from "./self-host/manifest.js";
6
6
  export * from "./self-host/project-generator.js";
7
7
  export * from "./self-host/update.js";
8
8
  export * from "./setup.js";
9
+ export * from "./help-command.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -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
  })) {
@@ -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,7 +186,7 @@ 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")
@@ -190,10 +202,10 @@ function validateEnvironmentTemplate(template, databaseEngine) {
190
202
  ? databaseEngine
191
203
  : key === "DATABASE_URL" && databaseEngine === "mysql"
192
204
  ? "mysql://user:password@host:3306/helyx"
193
- : key === "DATABASE_SSL" && databaseEngine === "mysql"
194
- ? "require"
205
+ : key === "DATABASE_SSL"
206
+ ? databaseSsl
195
207
  : definition.value;
196
- const expected = `${definition.optional ? "# " : ""}${key}=${value}`;
208
+ const expected = `${definition.optional ? "# " : ""}${key}='${value}'`;
197
209
  if (!lines.has(expected)) {
198
210
  throw new Error(`Self-host environment template does not match the bot contract: ${key}`);
199
211
  }
@@ -333,7 +345,7 @@ export const selfHostEnvironmentContract = {
333
345
  optional: false,
334
346
  },
335
347
  DATABASE_SSL: { value: "disable", optional: false },
336
- HELYX_DATABASE_STARTUP_MODE: { value: "apply", optional: true },
348
+ HELYX_DATABASE_STARTUP_MODE: { omit: true },
337
349
  HELYX_RUNTIME_PROFILE: { value: "self-hosted", optional: false },
338
350
  HELYX_DEPLOYMENT_ENVIRONMENT: { value: "self-hosted", optional: false },
339
351
  DISCORD_TOKEN: { value: "replace-me", optional: false },
@@ -348,71 +360,44 @@ export const selfHostEnvironmentContract = {
348
360
  HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS: { omit: true },
349
361
  DISCORD_INTERNAL_GUILD_ID: { omit: true },
350
362
  APPROVED_ROLES: { omit: true },
351
- HELYX_MODULE_PACKAGES: {
352
- value: "",
353
- optional: true,
354
- },
363
+ HELYX_MODULE_PACKAGES: { omit: true },
355
364
  HELYX_MODULE_CATALOGUE_PATH: {
356
365
  value: "./helyx.modules.json",
357
366
  optional: false,
358
367
  },
359
- HELYX_DASHBOARD_PUBLIC_URL: {
360
- value: "https://app.helyx.gg",
361
- optional: true,
362
- },
368
+ HELYX_DASHBOARD_PUBLIC_URL: { omit: true },
363
369
  HELYX_BOT_CONTROL_SECRET: { omit: true },
364
370
  HELYX_BOT_CONTROL_PORT: { omit: true },
365
371
  HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: { omit: true },
366
372
  HELYX_HOSTED_MODERATION_ENABLED: { omit: true },
367
- HELYX_GUILD_COMMAND_WRITES_ENABLED: { value: "false", optional: false },
373
+ HELYX_GUILD_COMMAND_WRITES_ENABLED: { value: "true", optional: false },
368
374
  HELYX_GUILD_COMMAND_WRITE_SCOPE: { value: "allowlist", optional: false },
369
- HELYX_GUILD_COMMAND_RELEASE_ID: { value: "", optional: true },
370
- HELYX_GLOBAL_COMMAND_MODE: { value: "legacy", optional: false },
371
- 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 },
372
378
  HELYX_GUILD_MEMBERS_ENABLED: { value: "false", optional: false },
373
379
  HELYX_CLOUD_CONNECT_ENABLED: { value: "true", optional: false },
374
380
  HELYX_CLOUD_ENROLMENT_TOKEN: { omit: true },
375
381
  HELYX_CLOUD_ENROLMENT_FILE: { omit: true },
376
382
  HELYX_DATA_ENCRYPTION_KEY: {
377
383
  value: "replace-with-a-base64-encoded-32-byte-key",
378
- optional: true,
384
+ optional: false,
379
385
  },
380
386
  HELYX_CLOUD_API_URL: { omit: true },
381
387
  HELYX_OAUTH_ENABLED: { omit: true },
382
388
  HELYX_OAUTH_INTERNAL_SECRET: { omit: true },
383
389
  HELYX_CLOUD_ENDPOINT: { omit: true },
384
- HELYX_OWNER_USER_IDS: {
385
- value: "12345678901234567",
386
- optional: true,
387
- },
390
+ HELYX_OWNER_USER_IDS: { omit: true },
388
391
  HELYX_SHARD_MODE: { value: "off", optional: false },
389
- HELYX_SHARD_COUNT: { value: "2", optional: true },
390
- HELYX_SHARD_SCOPE: { value: "local", optional: true },
391
- HELYX_SHARD_WORKERS_PER_REPLICA: { value: "2", optional: true },
392
- HELYX_SHARD_REDIS_URL: {
393
- value: "rediss://private-redis.example:6379",
394
- optional: true,
395
- },
396
- HELYX_SHARD_REDIS_NAMESPACE: {
397
- value: "helyx.self-host",
398
- optional: true,
399
- },
400
- HELYX_SHARD_GENERATION: {
401
- value: "local-20260728",
402
- optional: true,
403
- },
404
- HELYX_SHARD_DATABASE_CONNECTION_BUDGET: {
405
- value: "10",
406
- optional: true,
407
- },
408
- HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: {
409
- value: "2",
410
- optional: true,
411
- },
412
- HELYX_SHARD_WORKER_DATABASE_POOL_MAX: {
413
- value: "4",
414
- optional: true,
415
- },
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 },
416
401
  HELYX_CLOUD_AUDIT_RETENTION_DAYS: { omit: true },
417
402
  HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: { omit: true },
418
403
  HELYX_STATUS_ENABLED: { omit: true },
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.1",
3
+ "version": "1.8.0",
4
4
  "description": "Command-line tools for running and generating self-hosted Helyx deployments.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -52,9 +52,9 @@
52
52
  "build": "tsc -b"
53
53
  },
54
54
  "dependencies": {
55
- "@helyx/bot": ">=1.5.2 <2.0.0",
56
- "@helyx/config": ">=1.6.0 <2.0.0",
57
- "@helyx/database": ">=2.5.0 <3.0.0",
55
+ "@helyx/bot": ">=1.7.0 <2.0.0",
56
+ "@helyx/config": ">=1.7.0 <2.0.0",
57
+ "@helyx/database": ">=2.5.1 <3.0.0",
58
58
  "semver": "7.8.5",
59
59
  "zod": "4.6.7"
60
60
  },
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "catalogueVersion": 87,
3
+ "catalogueVersion": 89,
4
4
  "core": {
5
5
  "package": "@helyx/bot",
6
- "version": "1.5.4",
6
+ "version": "1.7.0",
7
7
  "availability": "published"
8
8
  },
9
9
  "generator": {
10
10
  "package": "@helyx/cli",
11
- "version": "1.6.1",
11
+ "version": "1.8.0",
12
12
  "command": "helyx",
13
13
  "availability": "published"
14
14
  },
15
15
  "databaseAdapters": [
16
16
  {
17
17
  "package": "@helyx/database",
18
- "version": "2.5.0"
18
+ "version": "2.5.1"
19
19
  },
20
20
  {
21
21
  "package": "@helyx/database-mysql",
@@ -273,7 +273,7 @@
273
273
  {
274
274
  "id": "helyx.account-gate",
275
275
  "package": "@helyx/module-account-gate",
276
- "version": "2.1.2",
276
+ "version": "2.1.3",
277
277
  "name": "Access Control",
278
278
  "description": "Require new members to complete configurable Discord interaction checks before gaining access.",
279
279
  "category": "Safety",
@@ -558,7 +558,7 @@
558
558
  {
559
559
  "id": "helyx.reactionroles",
560
560
  "package": "@helyx/module-reactionroles",
561
- "version": "1.1.2",
561
+ "version": "1.1.3",
562
562
  "name": "Reaction Roles",
563
563
  "description": "Let members select approved roles themselves.",
564
564
  "category": "Automation",
@@ -938,7 +938,7 @@
938
938
  {
939
939
  "id": "helyx.achievements",
940
940
  "package": "@helyx/module-achievements",
941
- "version": "2.1.3",
941
+ "version": "2.1.4",
942
942
  "name": "Achievements",
943
943
  "description": "Configurable member milestones, scores and optional rewards.",
944
944
  "category": "Gaming",
@@ -1,89 +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
- # Enable for loaded member workflows such as Welcome, Access Control or Levels,
13
- # with Server Members Intent also enabled in the Discord developer portal.
14
- HELYX_GUILD_MEMBERS_ENABLED=false
15
- # Database
16
- # Docker: keep the URL. Direct Node.js/managed hosting: use your provider URL.
17
- DATABASE_ENGINE=postgresql
18
- DATABASE_URL=postgresql://database:5432/helyx
19
- DATABASE_SSL=disable
20
- # Self-host startup applies pending migrations automatically (the default).
21
- # Hosted-only inspect mode is not a self-host setting; leave this commented.
22
- # HELYX_DATABASE_STARTUP_MODE=apply
23
- # Match your provider: disable=no TLS; require=TLS without certificate
24
- # verification; verify-full=TLS with certificate verification.
25
-
26
- # Included Docker database — both password values must match.
27
- PGUSER=helyx
28
- PGPASSWORD=replace-with-a-strong-password
29
- POSTGRES_DB=helyx
30
- POSTGRES_USER=helyx
31
- POSTGRES_PASSWORD=replace-with-a-strong-password
32
-
33
- # OPTIONAL EVERYDAY SETTINGS
34
-
35
- # Ready for dashboard linking, but remains unpaired until you use a one-use code.
36
- HELYX_CLOUD_CONNECT_ENABLED=true
37
- # Public dashboard origin used in bot-generated report links.
38
- # HELYX_DASHBOARD_PUBLIC_URL=https://app.helyx.gg
39
- # HELYX_OWNER_USER_IDS=12345678901234567
40
-
41
- # trace, debug, info, warn, error, fatal or silent
42
- LOG_LEVEL=info
43
-
44
- # SHARDING: OFF BY DEFAULT
45
-
46
- # `off` is the compatible self-host default. Keep shard-only values commented
47
- # in this mode; an empty value is still rejected as configured. MySQL must stay
48
- # off. Advanced PostgreSQL self-hosting may use `auto` under one
49
- # supervisor, or `fixed` with a deliberate total count. Local mode never needs
50
- # Redis. Coordinated multi-host self-hosting is outside the first release.
51
- HELYX_SHARD_MODE=off
52
- # HELYX_SHARD_SCOPE=local
53
- # HELYX_SHARD_COUNT=2
54
- # HELYX_SHARD_WORKERS_PER_REPLICA=2
55
- # HELYX_SHARD_REDIS_URL=rediss://private-redis.example:6379
56
- # HELYX_SHARD_REDIS_NAMESPACE=helyx.self-host
57
- # HELYX_SHARD_GENERATION=local-20260728
58
- # HELYX_SHARD_DATABASE_CONNECTION_BUDGET=10
59
- # HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX=2
60
- # HELYX_SHARD_WORKER_DATABASE_POOL_MAX=4
61
- # Redis URL and namespace are intentionally unsupported for the first
62
- # self-hosted release: do not uncomment them for local auto or fixed mode.
10
+ DISCORD_CLIENT_ID='12345678901234567'
11
+ DISCORD_TOKEN='replace-me'
12
+ HELYX_GUILD_MEMBERS_ENABLED='false'
13
+ HELYX_MESSAGE_CONTENT_ENABLED='false'
63
14
 
64
- # ADVANCED INTEGRATIONS — EDIT ONLY WHEN NEEDED
65
-
66
- # Additional reviewed module packages
67
- # HELYX_MODULE_PACKAGES=
68
-
69
- # Guild command writes remain off until explicitly enabled for known guild IDs.
70
- # For enabled-module server commands, set writes=true, retain allowlist scope,
71
- # add comma-separated Discord server IDs below, and use global mode platform-only.
72
- # See README.md before changing these defaults. Legacy mode still allows globals.
73
- HELYX_GUILD_COMMAND_WRITES_ENABLED=false
74
- HELYX_GUILD_COMMAND_WRITE_SCOPE=allowlist
75
- # HELYX_GUILD_COMMAND_RELEASE_ID=
76
- HELYX_GLOBAL_COMMAND_MODE=legacy
77
- # HELYX_GUILD_COMMAND_WRITE_GUILD_IDS=
78
-
79
- # DANGER ZONE — HELYX-MANAGED VALUES, DO NOT EDIT
80
-
81
- # Generated module selection
82
- HELYX_MODULE_CATALOGUE_PATH=./helyx.modules.json
83
-
84
- # Generated privately by npm run setup. Required by Cloud Connect, Giveaways,
85
- # and Invite Tracking; retain it unchanged across restarts and deployments.
86
- # HELYX_DATA_ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
87
-
88
- # Runtime mode
89
- 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'
@@ -9,6 +9,7 @@ COPY helyx.modules.json ./
9
9
 
10
10
  ENV HELYX_MODULE_CATALOGUE_PATH=/app/helyx.modules.json
11
11
  ENV NODE_ENV=production
12
+ RUN mkdir -p /app/.helyx && chown node:node /app/.helyx
12
13
 
13
14
  USER node
14
15
 
@@ -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
 
@@ -4,3 +4,8 @@ services:
4
4
  restart: unless-stopped
5
5
  env_file:
6
6
  - .env
7
+ volumes:
8
+ - helyx-private:/app/.helyx
9
+
10
+ volumes:
11
+ helyx-private:
@@ -4,6 +4,8 @@ services:
4
4
  restart: unless-stopped
5
5
  env_file:
6
6
  - .env
7
+ volumes:
8
+ - helyx-private:/app/.helyx
7
9
  depends_on:
8
10
  database:
9
11
  condition: service_healthy
@@ -25,3 +27,4 @@ services:
25
27
 
26
28
  volumes:
27
29
  helyx-database:
30
+ helyx-private:
@@ -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,116 +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,
197
+ "Runtime",
198
+ ["HELYX_RUNTIME_PROFILE", "HELYX_DEPLOYMENT_ENVIRONMENT", "NODE_ENV"],
193
199
  ],
194
- ["DISCORD BOT", /^(?:DISCORD_TOKEN|DISCORD_CLIENT_ID)$/u],
195
200
  [
196
- "DISCORD SERVER COMMANDS",
197
- /^(?:HELYX_GUILD_COMMAND_|HELYX_GLOBAL_COMMAND_MODE)/u,
201
+ "Discord",
202
+ [
203
+ "DISCORD_CLIENT_ID",
204
+ "DISCORD_TOKEN",
205
+ "HELYX_GUILD_MEMBERS_ENABLED",
206
+ "HELYX_MESSAGE_CONTENT_ENABLED",
207
+ ],
198
208
  ],
209
+ ["Database", ["DATABASE_ENGINE", "DATABASE_URL", "DATABASE_SSL"]],
199
210
  [
200
- "DISCORD GATEWAY INTENTS",
201
- /^HELYX_(?:MESSAGE_CONTENT_ENABLED|GUILD_MEMBERS_ENABLED)$/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
+ ],
202
218
  ],
203
- ["DATABASE CONNECTION", /^DATABASE_|^HELYX_DATABASE_STARTUP_MODE$/u],
204
- ["BUNDLED DOCKER POSTGRESQL", /^(?:PGUSER|PGPASSWORD|POSTGRES_)/u],
205
219
  [
206
- "DASHBOARD AND OWNERS",
207
- /^(?:HELYX_CLOUD_CONNECT_ENABLED|HELYX_DASHBOARD_PUBLIC_URL|HELYX_OWNER_USER_IDS)$/u,
220
+ "Modules and dashboard",
221
+ [
222
+ "HELYX_MODULE_CATALOGUE_PATH",
223
+ "HELYX_CLOUD_CONNECT_ENABLED",
224
+ "HELYX_SHARD_MODE",
225
+ "LOG_LEVEL",
226
+ ],
208
227
  ],
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],
228
+ ["Keep this key unchanged across restarts and updates", [encryptionKey]],
214
229
  ];
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;
230
+ const standardKeys = new Set(environmentGroups.flatMap(([, keys]) => keys));
217
231
  const oldHeadings = new Set([
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",
218
244
  "AUTO MODERATION",
219
245
  "PRIVATE CONTROL - ADVANCED",
220
246
  "CLOUD CONNECT - ADVANCED",
221
247
  "OAUTH - ADVANCED",
222
248
  "STATUS REPORTING - OPTIONAL",
223
249
  "INTERNAL DEVELOPMENT - OPTIONAL",
224
- "REQUIRED — EDIT THESE VALUES",
225
- "Discord",
226
- "Database",
250
+ "REQUIRED ? EDIT THESE VALUES",
227
251
  "OPTIONAL EVERYDAY SETTINGS",
228
252
  "SHARDING: OFF BY DEFAULT",
229
- "ADVANCED INTEGRATIONS — EDIT ONLY WHEN NEEDED",
230
- "DANGER ZONE — HELYX-MANAGED VALUES, DO NOT EDIT",
253
+ "ADVANCED INTEGRATIONS ? EDIT ONLY WHEN NEEDED",
254
+ "DANGER ZONE ? HELYX-MANAGED VALUES, DO NOT EDIT",
231
255
  "Runtime mode",
232
256
  "Helyx self-hosted environment",
233
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),
234
262
  ]);
235
263
 
236
264
  export function formatEnvironment(content, { template = false } = {}) {
237
265
  const newline = content.includes("\r\n") ? "\r\n" : "\n";
238
- const groups = environmentGroups.map(() => []);
266
+ const sections = [
267
+ ...environmentGroups,
268
+ ["Bundled Docker database", []],
269
+ ["Custom settings", []],
270
+ ];
271
+ const groups = sections.map(() => []);
239
272
  let pending = [];
240
273
  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
274
  for (const record of records(content)) {
253
275
  const text = record.text.replace(/\r\n/gu, "\n").replace(/\n$/u, "");
254
276
  const heading = text.match(/^#\s*(.*?)\s*$/u)?.[1];
255
277
  if (
256
278
  heading !== undefined &&
257
- (oldHeadings.has(heading) ||
258
- /^=+$/u.test(heading) ||
259
- environmentGroups.some(([title]) => heading === title))
279
+ (template || oldHeadings.has(heading) || /^=+$/u.test(heading))
260
280
  )
261
281
  continue;
262
282
  const key =
263
283
  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)) {
284
+ if (key && (!record.key || (template && !standardKeys.has(key)))) {
265
285
  pending = [];
266
286
  continue;
267
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);
298
336
  if (template)
299
337
  for (const key of Object.keys(before))
300
- if (omittedExampleSetting.test(key)) delete before[key];
338
+ if (!standardKeys.has(key)) delete before[key];
301
339
  const after = parseEnv(formatted);
302
340
  if (
303
341
  Object.keys(before).length !== Object.keys(after).length ||
@@ -443,6 +481,7 @@ export async function runWizard({
443
481
  validate = () => {},
444
482
  fallback,
445
483
  optional = false,
484
+ normalize = (value) => value,
446
485
  } = {},
447
486
  ) => {
448
487
  const current = fallback ?? values[key] ?? "";
@@ -456,8 +495,10 @@ export async function runWizard({
456
495
  `${label} [${display}]${optional ? " (Enter keeps; - clears)" : " (Enter keeps)"}: `,
457
496
  { secret: secret && !visibleInput },
458
497
  );
459
- const value = optional && answer === "-" ? "" : answer || current;
460
498
  try {
499
+ const value = normalize(
500
+ optional && answer === "-" ? "" : answer || current,
501
+ );
461
502
  if (placeholder.test(value))
462
503
  throw new WizardError(
463
504
  "Replace the example placeholder with your own value.",
@@ -466,7 +507,14 @@ export async function runWizard({
466
507
  encodeValue(value);
467
508
  validate(value);
468
509
  values[key] = value;
469
- 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;
470
518
  return value;
471
519
  } catch (error) {
472
520
  io.print(error.message);
@@ -490,8 +538,7 @@ export async function runWizard({
490
538
  const bundledAvailable =
491
539
  engine === "postgresql" &&
492
540
  defaults.DATABASE_ENGINE === "postgresql" &&
493
- (await optionalRead(join(directory, "compose.yaml"))) !== null &&
494
- Boolean(defaults.POSTGRES_PASSWORD);
541
+ (await optionalRead(join(directory, "compose.yaml"))) !== null;
495
542
  let databaseMode = "external";
496
543
  if (bundledAvailable) {
497
544
  for (;;) {
@@ -543,7 +590,11 @@ export async function runWizard({
543
590
  DATABASE_SSL: "disable",
544
591
  });
545
592
  } else {
546
- 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(
547
598
  "DATABASE_URL",
548
599
  "Database connection URL (typing and paste visible)",
549
600
  {
@@ -555,8 +606,30 @@ export async function runWizard({
555
606
  ? ""
556
607
  : values.DATABASE_URL,
557
608
  validate: (value) => validateDatabaseUrl(value, engine),
609
+ normalize: (value) => normalizeDatabaseUrl(value, engine),
558
610
  },
559
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
+ }
560
633
  io.print(
561
634
  "SSL: disable = no TLS; require = TLS without certificate verification; verify-full = TLS with certificate verification. Use your provider's requirement.",
562
635
  );
@@ -687,6 +760,17 @@ export async function runWizard({
687
760
  HELYX_RUNTIME_PROFILE: "self-hosted",
688
761
  HELYX_DEPLOYMENT_ENVIRONMENT: "self-hosted",
689
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
+ }
690
774
  if (engine === "mysql") Object.assign(changes, { HELYX_SHARD_MODE: "off" });
691
775
  const updated = formatEnvironment(
692
776
  updateEnvironment(original ?? template, changes),