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