@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 +10 -0
- package/README.md +6 -0
- package/dist/cli.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/self-host/environment-builders.d.ts +12 -0
- package/dist/self-host/environment-builders.js +25 -0
- package/dist/self-host/manifest.d.ts +16 -1
- package/dist/self-host/manifest.js +18 -5
- package/dist/self-host/project-generator.js +13 -3
- package/package.json +1 -1
- package/self-host-catalogue.json +2 -2
- package/templates/self-host/README.md +25 -0
- package/templates/self-host/env-builder.bat +14 -0
- package/templates/self-host/env-builder.mjs +741 -0
- package/templates/self-host/env-builder.ps1 +13 -0
- package/templates/self-host/env-builder.sh +10 -0
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
|
-
}, {
|
|
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
|
|
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 =
|
|
110
|
-
|
|
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
package/self-host-catalogue.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"catalogueVersion":
|
|
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.
|
|
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" "$@"
|