git-multiverse 0.1.1 → 0.1.3
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/README.md +30 -7
- package/bin/git-multiverse.mjs +4 -1
- package/lib/app.mjs +478 -102
- package/native/linux-amd64/multiverse-api +0 -0
- package/native/win32-amd64/multiverse-api.exe +0 -0
- package/package.json +2 -1
- package/test/git-multiverse.test.mjs +0 -179
package/README.md
CHANGED
|
@@ -12,10 +12,11 @@ npm install -g git-multiverse
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
git-multiverse setup
|
|
15
|
-
git-multiverse
|
|
16
|
-
git-multiverse
|
|
15
|
+
git-multiverse start
|
|
16
|
+
git-multiverse stop
|
|
17
17
|
git-multiverse status
|
|
18
18
|
git-multiverse doctor
|
|
19
|
+
git-multiverse uninstall
|
|
19
20
|
```
|
|
20
21
|
|
|
21
22
|
## State files
|
|
@@ -26,16 +27,25 @@ git-multiverse doctor
|
|
|
26
27
|
|
|
27
28
|
## Setup flow
|
|
28
29
|
|
|
29
|
-
`git-multiverse setup` writes
|
|
30
|
+
`git-multiverse setup` writes local runtime files and starts Docker Compose automatically:
|
|
30
31
|
|
|
31
32
|
- `anhdt5/git-multiverse:<tag>`
|
|
32
|
-
- `neo4j:5.26-community` when using
|
|
33
|
+
- `neo4j:5.26-community` when using Docker with local database mode
|
|
34
|
+
|
|
35
|
+
Setup choices:
|
|
36
|
+
|
|
37
|
+
- runtime: `docker`
|
|
38
|
+
- database mode: `local` or `remote`
|
|
39
|
+
- Docker mode can use either local Neo4j in Compose or a remote Neo4j database
|
|
40
|
+
- Native + remote-database startup is planned, but not available until release
|
|
41
|
+
binaries are bundled or downloaded automatically
|
|
33
42
|
|
|
34
43
|
Security defaults:
|
|
35
44
|
|
|
36
45
|
- host ports bind to `127.0.0.1`
|
|
37
46
|
- `MULTIVERSE_MCP_ALLOW_ANONYMOUS=false`
|
|
38
47
|
- random admin and Neo4j passwords by default
|
|
48
|
+
- remote database URI, user, password, and database are required when using remote database mode
|
|
39
49
|
- secrets stay only in `~/.git-multiverse/.env`
|
|
40
50
|
|
|
41
51
|
## Non-interactive example
|
|
@@ -43,9 +53,22 @@ Security defaults:
|
|
|
43
53
|
```bash
|
|
44
54
|
git-multiverse setup \
|
|
45
55
|
--non-interactive \
|
|
46
|
-
--
|
|
56
|
+
--runtime docker \
|
|
57
|
+
--db-mode local \
|
|
47
58
|
--workspace /workspace/repos \
|
|
48
|
-
--admin-password 'replace-me' \
|
|
49
|
-
--neo4j-password 'replace-me-too' \
|
|
50
59
|
--image-tag latest
|
|
51
60
|
```
|
|
61
|
+
|
|
62
|
+
Docker with remote database example:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git-multiverse setup \
|
|
66
|
+
--non-interactive \
|
|
67
|
+
--runtime docker \
|
|
68
|
+
--db-mode remote \
|
|
69
|
+
--workspace /workspace/repos \
|
|
70
|
+
--neo4j-uri neo4j+s://db.example.com:7687 \
|
|
71
|
+
--neo4j-user neo4j \
|
|
72
|
+
--neo4j-password 'replace-me' \
|
|
73
|
+
--neo4j-database neo4j
|
|
74
|
+
```
|
package/bin/git-multiverse.mjs
CHANGED
package/lib/app.mjs
CHANGED
|
@@ -6,13 +6,15 @@ import process from "node:process";
|
|
|
6
6
|
import net from "node:net";
|
|
7
7
|
import readline from "node:readline/promises";
|
|
8
8
|
import readlineModule from "node:readline";
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
|
|
11
12
|
export const APP_IMAGE = "anhdt5/git-multiverse";
|
|
12
13
|
export const NEO4J_IMAGE = "neo4j:5.26-community";
|
|
13
14
|
export const DEFAULT_IMAGE_TAG = "latest";
|
|
14
15
|
export const DEFAULT_PORT = "18081";
|
|
15
16
|
export const STATE_DIR_NAME = ".git-multiverse";
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
18
|
|
|
17
19
|
export const colors = {
|
|
18
20
|
reset: "\x1b[0m",
|
|
@@ -40,15 +42,24 @@ export async function main(argv, overrides = {}) {
|
|
|
40
42
|
case "setup":
|
|
41
43
|
await cmdSetup(flags, overrides, io);
|
|
42
44
|
break;
|
|
43
|
-
case "
|
|
44
|
-
await
|
|
45
|
+
case "start":
|
|
46
|
+
await cmdStart(overrides, io);
|
|
45
47
|
break;
|
|
46
|
-
case "
|
|
47
|
-
await
|
|
48
|
+
case "stop":
|
|
49
|
+
await cmdStop(overrides, io);
|
|
50
|
+
break;
|
|
51
|
+
case "uninstall":
|
|
52
|
+
await cmdUninstall(overrides, io);
|
|
48
53
|
break;
|
|
49
54
|
case "status":
|
|
50
55
|
await cmdStatus(overrides, io);
|
|
51
56
|
break;
|
|
57
|
+
case "logs":
|
|
58
|
+
await cmdLogs(flags, overrides, io);
|
|
59
|
+
break;
|
|
60
|
+
case "verify":
|
|
61
|
+
await cmdVerify(overrides, io);
|
|
62
|
+
break;
|
|
52
63
|
case "doctor":
|
|
53
64
|
await cmdDoctor(overrides, io);
|
|
54
65
|
break;
|
|
@@ -106,18 +117,46 @@ export function getStatePaths(overrides = {}) {
|
|
|
106
117
|
homeDir,
|
|
107
118
|
stateDir,
|
|
108
119
|
envPath: path.join(stateDir, ".env"),
|
|
109
|
-
composePath: path.join(stateDir, "docker-compose.yaml")
|
|
120
|
+
composePath: path.join(stateDir, "docker-compose.yaml"),
|
|
121
|
+
binDir: path.join(stateDir, "bin"),
|
|
122
|
+
runDir: path.join(stateDir, "run"),
|
|
123
|
+
logDir: path.join(stateDir, "logs"),
|
|
124
|
+
nativeBinPath: path.join(stateDir, "bin", nativeBinaryName()),
|
|
125
|
+
apiPidPath: path.join(stateDir, "run", "api.pid"),
|
|
126
|
+
apiLogPath: path.join(stateDir, "logs", "api.log")
|
|
110
127
|
};
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
export function loadExistingSetupConfig(overrides = {}) {
|
|
131
|
+
const fsModule = overrides.fsModule || fs;
|
|
132
|
+
const { envPath } = getStatePaths(overrides);
|
|
133
|
+
if (!fsModule.existsSync(envPath)) return {};
|
|
134
|
+
try {
|
|
135
|
+
return parseEnvFile(fsModule.readFileSync(envPath, "utf8"));
|
|
136
|
+
} catch {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildSetupDraft(flags = {}, overrides = {}, currentConfig = {}) {
|
|
142
|
+
return createSetupConfig(flags, overrides, currentConfig);
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
export function createSetupConfig(flags = {}, overrides = {}, existingConfig = {}) {
|
|
114
146
|
const env = overrides.env || process.env;
|
|
147
|
+
const paths = getStatePaths(overrides);
|
|
115
148
|
const workspace = stringFlag(flags.workspace) || existingConfig.MULTIVERSE_HOST_WORKSPACE_DIR || path.join(getStatePaths(overrides).homeDir, "workspace", "repos");
|
|
116
|
-
const
|
|
117
|
-
stringFlag(flags
|
|
118
|
-
existingConfig.
|
|
119
|
-
env.
|
|
120
|
-
"
|
|
149
|
+
const runtime = normalizeRuntimeMode(
|
|
150
|
+
stringFlag(flags.runtime) ||
|
|
151
|
+
existingConfig.MULTIVERSE_RUNTIME_MODE ||
|
|
152
|
+
env.GIT_MULTIVERSE_RUNTIME ||
|
|
153
|
+
"docker"
|
|
154
|
+
);
|
|
155
|
+
const dbMode = normalizeDbMode(
|
|
156
|
+
stringFlag(flags["db-mode"]) ||
|
|
157
|
+
existingConfig.MULTIVERSE_DB_MODE ||
|
|
158
|
+
legacyNeo4jModeToDbMode(stringFlag(flags["neo4j-mode"]) || existingConfig.MULTIVERSE_NEO4J_MODE || env.GIT_MULTIVERSE_NEO4J_MODE) ||
|
|
159
|
+
(runtime === "docker" ? "local" : "remote")
|
|
121
160
|
);
|
|
122
161
|
const adminPassword = stringFlag(flags["admin-password"]) || existingConfig.MULTIVERSE_ADMIN_PASSWORD;
|
|
123
162
|
const neo4jPassword = stringFlag(flags["neo4j-password"]) || existingConfig.MULTIVERSE_NEO4J_PASSWORD;
|
|
@@ -130,12 +169,13 @@ export function createSetupConfig(flags = {}, overrides = {}, existingConfig = {
|
|
|
130
169
|
MULTIVERSE_HOST: "0.0.0.0",
|
|
131
170
|
MULTIVERSE_PORT: DEFAULT_PORT,
|
|
132
171
|
MULTIVERSE_API_PORT: DEFAULT_PORT,
|
|
172
|
+
MULTIVERSE_RUNTIME_MODE: runtime,
|
|
133
173
|
MULTIVERSE_ADMIN_USER: "admin",
|
|
134
|
-
MULTIVERSE_ADMIN_PASSWORD: adminPassword ||
|
|
174
|
+
MULTIVERSE_ADMIN_PASSWORD: adminPassword || randomPassword(24, overrides.randomBytesImpl),
|
|
135
175
|
MULTIVERSE_MCP_ALLOW_ANONYMOUS: "false",
|
|
136
|
-
MULTIVERSE_WORKSPACE_DIR: "/workspace/repos",
|
|
176
|
+
MULTIVERSE_WORKSPACE_DIR: runtime === "native" ? workspace : "/workspace/repos",
|
|
137
177
|
MULTIVERSE_HOST_WORKSPACE_DIR: workspace,
|
|
138
|
-
DOCS_STORAGE_PATH: "/workspace/docs",
|
|
178
|
+
DOCS_STORAGE_PATH: runtime === "native" ? path.join(paths.stateDir, "docs") : "/workspace/docs",
|
|
139
179
|
MULTIVERSE_ENABLE_AI: enableAiFlag !== null ? enableAiFlag : (existingConfig.MULTIVERSE_ENABLE_AI || "false"),
|
|
140
180
|
NINEROUTER_API_BASE_URL: cleanBaseUrl(stringFlag(flags["llm-base-url"]), "llm") || existingConfig.NINEROUTER_API_BASE_URL || "",
|
|
141
181
|
NINEROUTER_MODEL_NAME: stringFlag(flags["llm-model"]) || existingConfig.NINEROUTER_MODEL_NAME || "",
|
|
@@ -145,22 +185,25 @@ export function createSetupConfig(flags = {}, overrides = {}, existingConfig = {
|
|
|
145
185
|
EMBEDDING_MODEL_NAME: stringFlag(flags["embedding-model"]) || existingConfig.EMBEDDING_MODEL_NAME || "",
|
|
146
186
|
EMBEDDING_API_KEY: stringFlag(flags["embedding-key"]) || existingConfig.EMBEDDING_API_KEY || "",
|
|
147
187
|
MULTIVERSE_IMAGE_TAG: imageTag,
|
|
148
|
-
|
|
188
|
+
MULTIVERSE_DB_MODE: dbMode,
|
|
189
|
+
MULTIVERSE_NEO4J_MODE: dbMode === "local" ? "managed" : "external",
|
|
149
190
|
MULTIVERSE_NEO4J_DATABASE: stringFlag(flags["neo4j-database"]) || existingConfig.MULTIVERSE_NEO4J_DATABASE || "neo4j",
|
|
150
191
|
MULTIVERSE_NEO4J_USER: stringFlag(flags["neo4j-user"]) || existingConfig.MULTIVERSE_NEO4J_USER || "neo4j",
|
|
151
192
|
MULTIVERSE_NEO4J_URI: "",
|
|
152
193
|
MULTIVERSE_NEO4J_PASSWORD: ""
|
|
153
194
|
};
|
|
154
195
|
|
|
155
|
-
if (
|
|
196
|
+
if (dbMode === "local") {
|
|
156
197
|
config.MULTIVERSE_NEO4J_URI = "neo4j://neo4j:7687";
|
|
157
|
-
config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD ||
|
|
198
|
+
config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD || randomPassword(24, overrides.randomBytesImpl);
|
|
158
199
|
} else {
|
|
159
200
|
config.MULTIVERSE_NEO4J_URI = stringFlag(flags["neo4j-uri"]) || existingConfig.MULTIVERSE_NEO4J_URI || "";
|
|
160
201
|
config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD || "";
|
|
161
202
|
}
|
|
162
203
|
|
|
163
|
-
|
|
204
|
+
if (!overrides.skipValidation) {
|
|
205
|
+
validateConfig(config, { nonInteractive: toBool(flags["non-interactive"]) });
|
|
206
|
+
}
|
|
164
207
|
return config;
|
|
165
208
|
}
|
|
166
209
|
|
|
@@ -168,26 +211,32 @@ export function validateConfig(config, options = {}) {
|
|
|
168
211
|
if (!path.isAbsolute(config.MULTIVERSE_HOST_WORKSPACE_DIR)) {
|
|
169
212
|
throw new Error("Workspace path must be absolute.");
|
|
170
213
|
}
|
|
214
|
+
if (!["docker", "native"].includes(config.MULTIVERSE_RUNTIME_MODE)) {
|
|
215
|
+
throw new Error("Runtime mode must be docker or native.");
|
|
216
|
+
}
|
|
171
217
|
if (!config.MULTIVERSE_ADMIN_PASSWORD) {
|
|
172
218
|
throw new Error("Admin password is required.");
|
|
173
219
|
}
|
|
174
|
-
if (!["
|
|
175
|
-
throw new Error("
|
|
220
|
+
if (!["local", "remote"].includes(config.MULTIVERSE_DB_MODE)) {
|
|
221
|
+
throw new Error("Database mode must be local or remote.");
|
|
222
|
+
}
|
|
223
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native" && config.MULTIVERSE_DB_MODE !== "remote") {
|
|
224
|
+
throw new Error("Native runtime requires remote database mode.");
|
|
176
225
|
}
|
|
177
|
-
if (config.
|
|
226
|
+
if (config.MULTIVERSE_DB_MODE === "local") {
|
|
178
227
|
if (!config.MULTIVERSE_NEO4J_PASSWORD) {
|
|
179
|
-
throw new Error("Neo4j password is required for
|
|
228
|
+
throw new Error("Neo4j password is required for local database mode.");
|
|
180
229
|
}
|
|
181
230
|
return;
|
|
182
231
|
}
|
|
183
232
|
if (!config.MULTIVERSE_NEO4J_URI) {
|
|
184
|
-
throw new Error("MULTIVERSE_NEO4J_URI is required for
|
|
233
|
+
throw new Error("MULTIVERSE_NEO4J_URI is required for remote database mode.");
|
|
185
234
|
}
|
|
186
235
|
if (!config.MULTIVERSE_NEO4J_USER) {
|
|
187
|
-
throw new Error("MULTIVERSE_NEO4J_USER is required for
|
|
236
|
+
throw new Error("MULTIVERSE_NEO4J_USER is required for remote database mode.");
|
|
188
237
|
}
|
|
189
|
-
if (!config.MULTIVERSE_NEO4J_PASSWORD
|
|
190
|
-
throw new Error("MULTIVERSE_NEO4J_PASSWORD is required for
|
|
238
|
+
if (!config.MULTIVERSE_NEO4J_PASSWORD) {
|
|
239
|
+
throw new Error("MULTIVERSE_NEO4J_PASSWORD is required for remote database mode.");
|
|
191
240
|
}
|
|
192
241
|
}
|
|
193
242
|
|
|
@@ -196,6 +245,7 @@ export function renderEnvFile(config) {
|
|
|
196
245
|
"MULTIVERSE_HOST",
|
|
197
246
|
"MULTIVERSE_PORT",
|
|
198
247
|
"MULTIVERSE_API_PORT",
|
|
248
|
+
"MULTIVERSE_RUNTIME_MODE",
|
|
199
249
|
"MULTIVERSE_ADMIN_USER",
|
|
200
250
|
"MULTIVERSE_ADMIN_PASSWORD",
|
|
201
251
|
"MULTIVERSE_MCP_ALLOW_ANONYMOUS",
|
|
@@ -211,6 +261,7 @@ export function renderEnvFile(config) {
|
|
|
211
261
|
"EMBEDDING_MODEL_NAME",
|
|
212
262
|
"EMBEDDING_API_KEY",
|
|
213
263
|
"MULTIVERSE_IMAGE_TAG",
|
|
264
|
+
"MULTIVERSE_DB_MODE",
|
|
214
265
|
"MULTIVERSE_NEO4J_MODE",
|
|
215
266
|
"MULTIVERSE_NEO4J_URI",
|
|
216
267
|
"MULTIVERSE_NEO4J_USER",
|
|
@@ -226,6 +277,9 @@ export function renderEnvFile(config) {
|
|
|
226
277
|
}
|
|
227
278
|
|
|
228
279
|
export function renderComposeFile(config) {
|
|
280
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
281
|
+
return "# Native runtime selected. Docker Compose is not used.\n";
|
|
282
|
+
}
|
|
229
283
|
const lines = [
|
|
230
284
|
"services:",
|
|
231
285
|
" multiverse:",
|
|
@@ -241,11 +295,11 @@ export function renderComposeFile(config) {
|
|
|
241
295
|
" ",
|
|
242
296
|
];
|
|
243
297
|
|
|
244
|
-
if (config.
|
|
298
|
+
if (config.MULTIVERSE_DB_MODE === "local") {
|
|
245
299
|
lines.push(" depends_on:", " - neo4j");
|
|
246
300
|
}
|
|
247
301
|
|
|
248
|
-
if (config.
|
|
302
|
+
if (config.MULTIVERSE_DB_MODE === "local") {
|
|
249
303
|
lines.push(
|
|
250
304
|
" neo4j:",
|
|
251
305
|
` image: ${NEO4J_IMAGE}`,
|
|
@@ -268,6 +322,9 @@ export function writeStateFiles(config, overrides = {}) {
|
|
|
268
322
|
const fsModule = overrides.fsModule || fs;
|
|
269
323
|
const paths = getStatePaths(overrides);
|
|
270
324
|
fsModule.mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
325
|
+
fsModule.mkdirSync(paths.binDir, { recursive: true, mode: 0o700 });
|
|
326
|
+
fsModule.mkdirSync(paths.runDir, { recursive: true, mode: 0o700 });
|
|
327
|
+
fsModule.mkdirSync(paths.logDir, { recursive: true, mode: 0o700 });
|
|
271
328
|
fsModule.chmodSync(paths.stateDir, 0o700);
|
|
272
329
|
fsModule.writeFileSync(paths.envPath, renderEnvFile(config), { mode: 0o600 });
|
|
273
330
|
fsModule.chmodSync(paths.envPath, 0o600);
|
|
@@ -281,7 +338,11 @@ export function loadConfig(overrides = {}) {
|
|
|
281
338
|
if (!fsModule.existsSync(envPath)) {
|
|
282
339
|
throw new Error(`missing ${envPath}. Run: git-multiverse setup`);
|
|
283
340
|
}
|
|
284
|
-
|
|
341
|
+
const config = parseEnvFile(fsModule.readFileSync(envPath, "utf8"));
|
|
342
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "non-docker") {
|
|
343
|
+
config.MULTIVERSE_RUNTIME_MODE = "native";
|
|
344
|
+
}
|
|
345
|
+
return config;
|
|
285
346
|
}
|
|
286
347
|
|
|
287
348
|
export function parseEnvFile(source) {
|
|
@@ -300,52 +361,79 @@ export function parseEnvFile(source) {
|
|
|
300
361
|
|
|
301
362
|
export async function cmdSetup(flags, overrides = {}, io = createIO(overrides)) {
|
|
302
363
|
const nonInteractive = toBool(flags["non-interactive"]);
|
|
303
|
-
const fsModule = overrides.fsModule || fs;
|
|
304
364
|
const paths = getStatePaths(overrides);
|
|
305
|
-
|
|
306
|
-
if (fsModule.existsSync(paths.envPath)) {
|
|
307
|
-
try {
|
|
308
|
-
existingConfig = parseEnvFile(fsModule.readFileSync(paths.envPath, "utf8"));
|
|
309
|
-
} catch (e) {
|
|
310
|
-
// ignore
|
|
311
|
-
}
|
|
312
|
-
}
|
|
365
|
+
const currentConfig = loadExistingSetupConfig(overrides);
|
|
313
366
|
|
|
314
|
-
const config = createSetupConfig(flags, overrides, existingConfig);
|
|
315
367
|
const interactive = !nonInteractive && isInteractive(overrides);
|
|
368
|
+
const config = buildSetupDraft(flags, { ...overrides, skipValidation: interactive }, currentConfig);
|
|
316
369
|
if (interactive) {
|
|
317
370
|
await runWizard(config, flags, overrides, io);
|
|
318
371
|
validateConfig(config);
|
|
319
372
|
}
|
|
373
|
+
ensureRuntimeSupported(config, overrides);
|
|
320
374
|
writeStateFiles(config, overrides);
|
|
321
375
|
io.log(`${colors.green}✔ Configuration successfully written:${colors.reset}`);
|
|
322
376
|
io.log(` - Env file: ${colors.cyan}${paths.envPath}${colors.reset} (Mode: 0600)`);
|
|
323
377
|
io.log(` - Docker Compose: ${colors.cyan}${paths.composePath}${colors.reset}`);
|
|
324
|
-
io.log(` -
|
|
378
|
+
io.log(` - Runtime: ${colors.cyan}${config.MULTIVERSE_RUNTIME_MODE}${colors.reset}`);
|
|
379
|
+
io.log(` - Database Mode: ${colors.cyan}${config.MULTIVERSE_DB_MODE}${colors.reset}`);
|
|
325
380
|
io.log(` - Admin password: ${colors.bold}${colors.green}${config.MULTIVERSE_ADMIN_PASSWORD}${colors.reset}`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
381
|
+
|
|
382
|
+
io.log(`\n${colors.green}${colors.bold}✔ Setup complete. Starting Multiverse automatically...${colors.reset}`);
|
|
383
|
+
await cmdStart(overrides, io);
|
|
384
|
+
io.log(`\n${colors.green}${colors.bold}✔ Verifying setup...${colors.reset}`);
|
|
385
|
+
await cmdVerify(overrides, io);
|
|
331
386
|
}
|
|
332
387
|
|
|
333
|
-
export async function
|
|
388
|
+
export async function cmdStart(overrides = {}, io = createIO(overrides)) {
|
|
389
|
+
const config = loadConfig(overrides);
|
|
390
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
391
|
+
await startNativeRuntime(config, overrides, io);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
334
394
|
const paths = getStatePaths(overrides);
|
|
335
395
|
ensureDockerAvailable(overrides);
|
|
336
396
|
runDockerCompose(["up", "-d"], overrides, io, paths);
|
|
337
397
|
}
|
|
338
398
|
|
|
339
|
-
export async function
|
|
399
|
+
export async function cmdStop(overrides = {}, io = createIO(overrides)) {
|
|
400
|
+
const config = loadConfig(overrides);
|
|
401
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
402
|
+
await stopNativeRuntime(overrides, io);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
340
405
|
const paths = getStatePaths(overrides);
|
|
341
406
|
ensureDockerAvailable(overrides);
|
|
342
407
|
runDockerCompose(["down"], overrides, io, paths);
|
|
343
408
|
}
|
|
344
409
|
|
|
410
|
+
export async function cmdUninstall(overrides = {}, io = createIO(overrides)) {
|
|
411
|
+
const fsModule = overrides.fsModule || fs;
|
|
412
|
+
const paths = getStatePaths(overrides);
|
|
413
|
+
if (fsModule.existsSync(paths.envPath)) {
|
|
414
|
+
const config = loadConfig(overrides);
|
|
415
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
416
|
+
await stopNativeRuntime(overrides, io);
|
|
417
|
+
} else {
|
|
418
|
+
ensureDockerAvailable(overrides);
|
|
419
|
+
runDockerCompose(["down", "--volumes"], overrides, io, paths);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
fsModule.rmSync(paths.stateDir, { recursive: true, force: true });
|
|
423
|
+
io.log(`${colors.green}✔ Removed ${paths.stateDir}${colors.reset}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
345
426
|
export async function cmdStatus(overrides = {}, io = createIO(overrides)) {
|
|
346
427
|
const paths = getStatePaths(overrides);
|
|
347
|
-
|
|
348
|
-
|
|
428
|
+
const config = loadConfig(overrides);
|
|
429
|
+
if (config.MULTIVERSE_RUNTIME_MODE !== "native") {
|
|
430
|
+
ensureDockerAvailable(overrides);
|
|
431
|
+
runDockerCompose(["ps"], overrides, io, paths);
|
|
432
|
+
} else {
|
|
433
|
+
const pid = readPid(paths, overrides);
|
|
434
|
+
io.log("runtime: native");
|
|
435
|
+
io.log(`pid: ${pid && isProcessRunning(pid, overrides) ? pid : "not running"}`);
|
|
436
|
+
}
|
|
349
437
|
const health = await checkHealth("http://127.0.0.1:18081/api/health", overrides.fetchImpl || globalThis.fetch);
|
|
350
438
|
io.log(`health: ${health.ok ? health.message : `unreachable (${health.message})`}`);
|
|
351
439
|
if (!health.ok) {
|
|
@@ -354,17 +442,54 @@ export async function cmdStatus(overrides = {}, io = createIO(overrides)) {
|
|
|
354
442
|
}
|
|
355
443
|
}
|
|
356
444
|
|
|
445
|
+
export async function cmdVerify(overrides = {}, io = createIO(overrides)) {
|
|
446
|
+
const paths = getStatePaths(overrides);
|
|
447
|
+
const config = loadConfig(overrides);
|
|
448
|
+
validateConfig(config);
|
|
449
|
+
|
|
450
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
451
|
+
const pid = readPid(paths, overrides);
|
|
452
|
+
if (!pid || !isProcessRunning(pid, overrides)) {
|
|
453
|
+
throw new Error("native runtime is not running");
|
|
454
|
+
}
|
|
455
|
+
io.log(`native runtime: running (pid ${pid})`);
|
|
456
|
+
} else {
|
|
457
|
+
ensureDockerAvailable(overrides);
|
|
458
|
+
io.log("docker runtime: configured");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const health = await waitForHealth(healthURL(config), overrides.fetchImpl || globalThis.fetch, {
|
|
462
|
+
healthAttempts: overrides.verifyHealthAttempts || overrides.healthAttempts || 60,
|
|
463
|
+
healthIntervalMs: overrides.verifyHealthIntervalMs || overrides.healthIntervalMs || 1000
|
|
464
|
+
});
|
|
465
|
+
if (!health.ok) {
|
|
466
|
+
throw new Error(`setup verification failed: API health unreachable (${health.message})`);
|
|
467
|
+
}
|
|
468
|
+
io.log(`health: ${health.message}`);
|
|
469
|
+
io.log(`${colors.green}✔ Setup verified. Multiverse is ready at http://127.0.0.1:${config.MULTIVERSE_API_PORT || DEFAULT_PORT}${colors.reset}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function cmdLogs(flags = {}, overrides = {}, io = createIO(overrides)) {
|
|
473
|
+
const config = loadConfig(overrides);
|
|
474
|
+
if (config.MULTIVERSE_RUNTIME_MODE !== "native") {
|
|
475
|
+
const paths = getStatePaths(overrides);
|
|
476
|
+
ensureDockerAvailable(overrides);
|
|
477
|
+
runDockerCompose(["logs", "--tail", stringFlag(flags.tail) || "200"], overrides, io, paths);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const fsModule = overrides.fsModule || fs;
|
|
481
|
+
const { apiLogPath } = getStatePaths(overrides);
|
|
482
|
+
if (!fsModule.existsSync(apiLogPath)) {
|
|
483
|
+
io.log(`No native log file yet: ${apiLogPath}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
io.log(tailText(fsModule.readFileSync(apiLogPath, "utf8"), Number(stringFlag(flags.tail) || 200)));
|
|
487
|
+
}
|
|
488
|
+
|
|
357
489
|
export async function cmdDoctor(overrides = {}, io = createIO(overrides)) {
|
|
358
490
|
const fsModule = overrides.fsModule || fs;
|
|
359
491
|
const paths = getStatePaths(overrides);
|
|
360
492
|
let failed = false;
|
|
361
|
-
const docker = checkCommand(["docker", "--version"], overrides);
|
|
362
|
-
printDoctor(io, "docker", docker.ok, docker.ok ? docker.output : "missing docker CLI");
|
|
363
|
-
failed ||= !docker.ok;
|
|
364
|
-
const compose = checkCommand(["docker", "compose", "version"], overrides);
|
|
365
|
-
printDoctor(io, "docker compose", compose.ok, compose.ok ? compose.output : "missing docker compose plugin");
|
|
366
|
-
failed ||= !compose.ok;
|
|
367
|
-
|
|
368
493
|
const envExists = fsModule.existsSync(paths.envPath);
|
|
369
494
|
printDoctor(io, "env file", envExists, envExists ? paths.envPath : "run git-multiverse setup");
|
|
370
495
|
failed ||= !envExists;
|
|
@@ -372,12 +497,25 @@ export async function cmdDoctor(overrides = {}, io = createIO(overrides)) {
|
|
|
372
497
|
let config = null;
|
|
373
498
|
if (envExists) {
|
|
374
499
|
config = parseEnvFile(fsModule.readFileSync(paths.envPath, "utf8"));
|
|
500
|
+
const dockerRuntime = config.MULTIVERSE_RUNTIME_MODE !== "native";
|
|
501
|
+
const docker = checkCommand(["docker", "--version"], overrides);
|
|
502
|
+
printDoctor(io, "docker", !dockerRuntime || docker.ok, dockerRuntime ? (docker.ok ? docker.output : "missing docker CLI") : "not required for native runtime");
|
|
503
|
+
failed ||= dockerRuntime && !docker.ok;
|
|
504
|
+
const compose = checkCommand(["docker", "compose", "version"], overrides);
|
|
505
|
+
printDoctor(io, "docker compose", !dockerRuntime || compose.ok, dockerRuntime ? (compose.ok ? compose.output : "missing docker compose plugin") : "not required for native runtime");
|
|
506
|
+
failed ||= dockerRuntime && !compose.ok;
|
|
375
507
|
const envMode = fsModule.statSync(paths.envPath).mode & 0o777;
|
|
376
508
|
const isWindows = os.platform() === "win32";
|
|
377
509
|
const modeOk = isWindows || envMode === 0o600;
|
|
378
510
|
printDoctor(io, "env mode", modeOk, `0${envMode.toString(8)}`);
|
|
379
511
|
failed ||= !modeOk;
|
|
380
|
-
printDoctor(io, "
|
|
512
|
+
printDoctor(io, "runtime", true, config.MULTIVERSE_RUNTIME_MODE || "docker");
|
|
513
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
514
|
+
const nativeBinary = resolveNativeBinary(overrides);
|
|
515
|
+
printDoctor(io, "native binary", nativeBinary.ok, nativeBinary.ok ? nativeBinary.path : nativeBinary.message);
|
|
516
|
+
failed ||= !nativeBinary.ok;
|
|
517
|
+
}
|
|
518
|
+
printDoctor(io, "database mode", true, config.MULTIVERSE_DB_MODE || legacyNeo4jModeToDbMode(config.MULTIVERSE_NEO4J_MODE));
|
|
381
519
|
printDoctor(io, "mcp anonymous", config.MULTIVERSE_MCP_ALLOW_ANONYMOUS === "false", `MULTIVERSE_MCP_ALLOW_ANONYMOUS=${config.MULTIVERSE_MCP_ALLOW_ANONYMOUS}`);
|
|
382
520
|
failed ||= config.MULTIVERSE_MCP_ALLOW_ANONYMOUS !== "false";
|
|
383
521
|
const adminOk = Boolean(config.MULTIVERSE_ADMIN_PASSWORD && config.MULTIVERSE_ADMIN_PASSWORD !== "admin");
|
|
@@ -510,7 +648,7 @@ export function runDockerCompose(args, overrides = {}, io = createIO(overrides),
|
|
|
510
648
|
if (!result.ok) {
|
|
511
649
|
throw new Error(`docker compose ${args.join(" ")} failed`);
|
|
512
650
|
}
|
|
513
|
-
io.log(`compose ${args.join(" ")} ok (${config.MULTIVERSE_NEO4J_MODE})`);
|
|
651
|
+
io.log(`compose ${args.join(" ")} ok (${config.MULTIVERSE_DB_MODE || legacyNeo4jModeToDbMode(config.MULTIVERSE_NEO4J_MODE)})`);
|
|
514
652
|
}
|
|
515
653
|
|
|
516
654
|
export function checkCommand(command, overrides = {}) {
|
|
@@ -529,6 +667,140 @@ export function execCommand(command, overrides = {}, options = {}) {
|
|
|
529
667
|
return { ok: !result?.error && result?.status === 0, output };
|
|
530
668
|
}
|
|
531
669
|
|
|
670
|
+
export function nativeBinaryName(platform = os.platform()) {
|
|
671
|
+
return platform === "win32" ? "multiverse-api.exe" : "multiverse-api";
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function nativePlatformDir(platform = os.platform(), arch = os.arch()) {
|
|
675
|
+
const normalizedArch = arch === "x64" ? "amd64" : arch;
|
|
676
|
+
return `${platform}-${normalizedArch}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function resolveNativeBinary(overrides = {}) {
|
|
680
|
+
const fsModule = overrides.fsModule || fs;
|
|
681
|
+
const paths = getStatePaths(overrides);
|
|
682
|
+
const candidates = [
|
|
683
|
+
paths.nativeBinPath,
|
|
684
|
+
path.join(overrides.packageRoot || PACKAGE_ROOT, "native", nativePlatformDir(overrides.platform || os.platform(), overrides.arch || os.arch()), nativeBinaryName(overrides.platform || os.platform())),
|
|
685
|
+
path.resolve(overrides.repoRoot || path.join(PACKAGE_ROOT, "..", ".."), "bin", nativeBinaryName(overrides.platform || os.platform()))
|
|
686
|
+
];
|
|
687
|
+
const found = candidates.find((candidate) => fsModule.existsSync(candidate));
|
|
688
|
+
if (!found) {
|
|
689
|
+
return { ok: false, message: `missing native API binary for ${nativePlatformDir(overrides.platform || os.platform(), overrides.arch || os.arch())}. Expected ${candidates.join(" or ")}` };
|
|
690
|
+
}
|
|
691
|
+
return { ok: true, path: found };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function installNativeBinary(overrides = {}) {
|
|
695
|
+
const fsModule = overrides.fsModule || fs;
|
|
696
|
+
const paths = getStatePaths(overrides);
|
|
697
|
+
const resolved = resolveNativeBinary(overrides);
|
|
698
|
+
if (!resolved.ok) throw new Error(resolved.message);
|
|
699
|
+
fsModule.mkdirSync(paths.binDir, { recursive: true, mode: 0o700 });
|
|
700
|
+
if (path.resolve(resolved.path) !== path.resolve(paths.nativeBinPath)) {
|
|
701
|
+
fsModule.copyFileSync(resolved.path, paths.nativeBinPath);
|
|
702
|
+
}
|
|
703
|
+
if ((overrides.platform || os.platform()) !== "win32") {
|
|
704
|
+
fsModule.chmodSync(paths.nativeBinPath, 0o700);
|
|
705
|
+
}
|
|
706
|
+
return paths.nativeBinPath;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function healthURL(config) {
|
|
710
|
+
return `http://127.0.0.1:${config.MULTIVERSE_API_PORT || DEFAULT_PORT}/api/health`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export async function startNativeRuntime(config, overrides = {}, io = createIO(overrides)) {
|
|
714
|
+
const fsModule = overrides.fsModule || fs;
|
|
715
|
+
const paths = getStatePaths(overrides);
|
|
716
|
+
fsModule.mkdirSync(paths.runDir, { recursive: true, mode: 0o700 });
|
|
717
|
+
fsModule.mkdirSync(paths.logDir, { recursive: true, mode: 0o700 });
|
|
718
|
+
|
|
719
|
+
const existingPid = readPid(paths, overrides);
|
|
720
|
+
if (existingPid && isProcessRunning(existingPid, overrides)) {
|
|
721
|
+
io.log(`native runtime already running (pid ${existingPid})`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const binaryPath = installNativeBinary(overrides);
|
|
726
|
+
const env = { ...(overrides.env || process.env), ...config };
|
|
727
|
+
const logFd = fsModule.openSync(paths.apiLogPath, "a");
|
|
728
|
+
let child;
|
|
729
|
+
try {
|
|
730
|
+
child = (overrides.spawnImpl || spawn)(binaryPath, [], {
|
|
731
|
+
cwd: paths.stateDir,
|
|
732
|
+
detached: true,
|
|
733
|
+
env,
|
|
734
|
+
stdio: ["ignore", logFd, logFd]
|
|
735
|
+
});
|
|
736
|
+
} finally {
|
|
737
|
+
fsModule.closeSync?.(logFd);
|
|
738
|
+
}
|
|
739
|
+
child.unref?.();
|
|
740
|
+
fsModule.writeFileSync(paths.apiPidPath, `${child.pid}\n`, { mode: 0o600 });
|
|
741
|
+
io.log(`native runtime started (pid ${child.pid})`);
|
|
742
|
+
io.log(`logs: ${paths.apiLogPath}`);
|
|
743
|
+
|
|
744
|
+
if (!overrides.skipHealthWait) {
|
|
745
|
+
const health = await waitForHealth(healthURL(config), overrides.fetchImpl || globalThis.fetch, overrides);
|
|
746
|
+
if (!health.ok) {
|
|
747
|
+
io.log(`${colors.yellow}health check not ready yet: ${health.message}${colors.reset}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export async function stopNativeRuntime(overrides = {}, io = createIO(overrides)) {
|
|
753
|
+
const fsModule = overrides.fsModule || fs;
|
|
754
|
+
const paths = getStatePaths(overrides);
|
|
755
|
+
const pid = readPid(paths, overrides);
|
|
756
|
+
if (!pid) {
|
|
757
|
+
io.log("native runtime is not running");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (!isProcessRunning(pid, overrides)) {
|
|
761
|
+
fsModule.rmSync(paths.apiPidPath, { force: true });
|
|
762
|
+
io.log("removed stale native runtime pid file");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const kill = overrides.processKillImpl || process.kill;
|
|
766
|
+
kill(pid, "SIGTERM");
|
|
767
|
+
fsModule.rmSync(paths.apiPidPath, { force: true });
|
|
768
|
+
io.log(`native runtime stopped (pid ${pid})`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function readPid(paths = getStatePaths(), overrides = {}) {
|
|
772
|
+
const fsModule = overrides.fsModule || fs;
|
|
773
|
+
if (!fsModule.existsSync(paths.apiPidPath)) return 0;
|
|
774
|
+
const pid = Number(fsModule.readFileSync(paths.apiPidPath, "utf8").trim());
|
|
775
|
+
return Number.isInteger(pid) && pid > 0 ? pid : 0;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function isProcessRunning(pid, overrides = {}) {
|
|
779
|
+
try {
|
|
780
|
+
(overrides.processKillImpl || process.kill)(pid, 0);
|
|
781
|
+
return true;
|
|
782
|
+
} catch {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function waitForHealth(url, fetchImpl, overrides = {}) {
|
|
788
|
+
const attempts = Number(overrides.healthAttempts || 30);
|
|
789
|
+
const intervalMs = Number(overrides.healthIntervalMs || 500);
|
|
790
|
+
let last = { ok: false, message: "not checked" };
|
|
791
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
792
|
+
last = await checkHealth(url, fetchImpl);
|
|
793
|
+
if (last.ok) return last;
|
|
794
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
795
|
+
}
|
|
796
|
+
return last;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function tailText(text, lineCount) {
|
|
800
|
+
const lines = text.split(/\r?\n/);
|
|
801
|
+
return lines.slice(Math.max(0, lines.length - Math.max(1, lineCount))).join("\n");
|
|
802
|
+
}
|
|
803
|
+
|
|
532
804
|
export async function checkHealth(url, fetchImpl = globalThis.fetch) {
|
|
533
805
|
if (typeof fetchImpl !== "function") {
|
|
534
806
|
return { ok: false, message: "fetch unavailable" };
|
|
@@ -552,50 +824,74 @@ export async function checkPortStatus(port, netModule = net) {
|
|
|
552
824
|
});
|
|
553
825
|
}
|
|
554
826
|
|
|
555
|
-
function showPageHeader(pageNum, title, io) {
|
|
827
|
+
function showPageHeader(pageNum, title, io, context = {}) {
|
|
556
828
|
console.clear();
|
|
557
829
|
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
558
|
-
io.log(`${colors.bold}${colors.cyan}
|
|
559
|
-
io.log(`
|
|
830
|
+
io.log(`${colors.bold}${colors.cyan} Welcome to Multiverse Setup`);
|
|
831
|
+
io.log(` Step ${pageNum} of 5: ${title}${colors.reset}`);
|
|
560
832
|
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
561
|
-
|
|
833
|
+
if (context.envPath) {
|
|
834
|
+
io.log(`${context.hasExistingConfig ? "Using" : "Creating"} config: ${colors.cyan}${context.envPath}${colors.reset}`);
|
|
835
|
+
}
|
|
836
|
+
io.log(`${colors.yellow}Press Enter to keep saved/default values.${colors.reset}`);
|
|
562
837
|
io.log("");
|
|
563
838
|
}
|
|
564
839
|
|
|
565
840
|
export async function runWizard(config, flags, overrides = {}, io = createIO(overrides)) {
|
|
566
841
|
const rl = readline.createInterface({ input: overrides.stdin || process.stdin, output: overrides.stdout || process.stdout });
|
|
842
|
+
const paths = getStatePaths(overrides);
|
|
843
|
+
const wizardContext = {
|
|
844
|
+
envPath: paths.envPath,
|
|
845
|
+
hasExistingConfig: (overrides.fsModule || fs).existsSync(paths.envPath)
|
|
846
|
+
};
|
|
567
847
|
try {
|
|
568
848
|
// ── Page 1: System & Workspace ──────────────────────────────────
|
|
569
|
-
showPageHeader(1, "System & Workspace", io);
|
|
570
|
-
if (!flags
|
|
571
|
-
|
|
849
|
+
showPageHeader(1, "System & Workspace", io, wizardContext);
|
|
850
|
+
if (!flags.runtime) {
|
|
851
|
+
const runtimeOptions = [{ name: "Docker Compose (recommended)", value: "docker" }];
|
|
852
|
+
const nativeBinary = resolveNativeBinary(overrides);
|
|
853
|
+
if (nativeBinary.ok) {
|
|
854
|
+
runtimeOptions.push({ name: "Native binary + remote Neo4j", value: "native" });
|
|
855
|
+
} else {
|
|
856
|
+
io.log(`${colors.yellow}Native runtime is not available in this npm package for ${nativePlatformDir(overrides.platform || os.platform(), overrides.arch || os.arch())}.${colors.reset}`);
|
|
857
|
+
}
|
|
858
|
+
config.MULTIVERSE_RUNTIME_MODE = await promptMenu(rl, "Select runtime mode:", runtimeOptions, "docker", overrides);
|
|
859
|
+
}
|
|
860
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
861
|
+
config.MULTIVERSE_DB_MODE = "remote";
|
|
862
|
+
config.MULTIVERSE_NEO4J_MODE = "external";
|
|
863
|
+
} else if (!flags["db-mode"] && !flags["neo4j-mode"]) {
|
|
864
|
+
config.MULTIVERSE_DB_MODE = await promptMenu(rl, "Select database mode:", [
|
|
865
|
+
{ name: "Local Neo4j container", value: "local" },
|
|
866
|
+
{ name: "Remote database", value: "remote" }
|
|
867
|
+
], config.MULTIVERSE_DB_MODE, overrides);
|
|
868
|
+
config.MULTIVERSE_NEO4J_MODE = config.MULTIVERSE_DB_MODE === "local" ? "managed" : "external";
|
|
572
869
|
}
|
|
573
870
|
if (!flags.workspace) {
|
|
574
871
|
config.MULTIVERSE_HOST_WORKSPACE_DIR = await prompt(rl, "Workspace host path", config.MULTIVERSE_HOST_WORKSPACE_DIR);
|
|
575
872
|
}
|
|
576
873
|
|
|
577
874
|
// ── Page 2: Security & Credentials ──────────────────────────────
|
|
578
|
-
showPageHeader(2, "Security & Credentials", io);
|
|
875
|
+
showPageHeader(2, "Security & Credentials", io, wizardContext);
|
|
579
876
|
if (!flags["admin-password"]) {
|
|
580
|
-
config.MULTIVERSE_ADMIN_PASSWORD = await
|
|
877
|
+
config.MULTIVERSE_ADMIN_PASSWORD = await promptSecret(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD, { required: true });
|
|
581
878
|
}
|
|
582
|
-
if (config.
|
|
879
|
+
if (config.MULTIVERSE_DB_MODE === "local") {
|
|
583
880
|
if (!flags["neo4j-password"]) {
|
|
584
|
-
config.MULTIVERSE_NEO4J_PASSWORD = await
|
|
881
|
+
config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Local Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
|
|
585
882
|
}
|
|
586
883
|
} else {
|
|
587
884
|
if (!flags["neo4j-uri"]) config.MULTIVERSE_NEO4J_URI = await prompt(rl, "Neo4j URI", config.MULTIVERSE_NEO4J_URI);
|
|
588
885
|
if (!flags["neo4j-user"]) config.MULTIVERSE_NEO4J_USER = await prompt(rl, "Neo4j user", config.MULTIVERSE_NEO4J_USER);
|
|
589
|
-
if (!flags["neo4j-password"]) config.MULTIVERSE_NEO4J_PASSWORD = await
|
|
886
|
+
if (!flags["neo4j-password"]) config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Remote Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
|
|
590
887
|
if (!flags["neo4j-database"]) config.MULTIVERSE_NEO4J_DATABASE = await prompt(rl, "Neo4j database", config.MULTIVERSE_NEO4J_DATABASE);
|
|
591
888
|
}
|
|
592
889
|
|
|
593
890
|
// ── Page 3: AI Provider (LLM) ───────────────────────────────────
|
|
594
|
-
showPageHeader(3, "AI Provider (LLM)", io);
|
|
891
|
+
showPageHeader(3, "AI Provider (LLM)", io, wizardContext);
|
|
595
892
|
if (!hasAny(flags, ["enable-ai", "llm-base-url", "llm-model", "llm-key"])) {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
if (/^y(es)?$/i.test(answer)) {
|
|
893
|
+
const answer = await promptMenu(rl, "Configure AI provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_AI === "true" ? "yes" : "no", overrides);
|
|
894
|
+
if (answer === "yes") {
|
|
599
895
|
config.MULTIVERSE_ENABLE_AI = "true";
|
|
600
896
|
io.log("");
|
|
601
897
|
const providerOptions = [
|
|
@@ -620,7 +916,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
620
916
|
else defaultProvider = "custom";
|
|
621
917
|
}
|
|
622
918
|
|
|
623
|
-
const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider);
|
|
919
|
+
const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider, overrides);
|
|
624
920
|
io.log("");
|
|
625
921
|
|
|
626
922
|
let baseUrl = "";
|
|
@@ -671,18 +967,17 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
671
967
|
|
|
672
968
|
config.NINEROUTER_API_BASE_URL = baseUrl;
|
|
673
969
|
config.NINEROUTER_MODEL_NAME = modelName;
|
|
674
|
-
config.NINEROUTER_API_KEY = await
|
|
970
|
+
config.NINEROUTER_API_KEY = await promptSecret(rl, "LLM API key", config.NINEROUTER_API_KEY);
|
|
675
971
|
} else {
|
|
676
972
|
config.MULTIVERSE_ENABLE_AI = "false";
|
|
677
973
|
}
|
|
678
974
|
}
|
|
679
975
|
|
|
680
976
|
// ── Page 4: Vector Embeddings ───────────────────────────────────
|
|
681
|
-
showPageHeader(4, "Vector Embeddings", io);
|
|
977
|
+
showPageHeader(4, "Vector Embeddings", io, wizardContext);
|
|
682
978
|
if (!hasAny(flags, ["enable-embeddings", "embedding-base-url", "embedding-model", "embedding-key"])) {
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
if (/^y(es)?$/i.test(answer)) {
|
|
979
|
+
const answer = await promptMenu(rl, "Configure embedding provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "yes" : "no", overrides);
|
|
980
|
+
if (answer === "yes") {
|
|
686
981
|
config.MULTIVERSE_ENABLE_EMBEDDINGS = "true";
|
|
687
982
|
io.log("");
|
|
688
983
|
const embedOptions = [
|
|
@@ -699,7 +994,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
699
994
|
else defaultEmbed = "custom";
|
|
700
995
|
}
|
|
701
996
|
|
|
702
|
-
const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed);
|
|
997
|
+
const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed, overrides);
|
|
703
998
|
io.log("");
|
|
704
999
|
|
|
705
1000
|
let baseUrl = "";
|
|
@@ -738,23 +1033,22 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
738
1033
|
|
|
739
1034
|
config.EMBEDDING_API_BASE_URL = baseUrl;
|
|
740
1035
|
config.EMBEDDING_MODEL_NAME = modelName;
|
|
741
|
-
config.EMBEDDING_API_KEY = await
|
|
1036
|
+
config.EMBEDDING_API_KEY = await promptSecret(rl, "Embedding API key", config.EMBEDDING_API_KEY);
|
|
742
1037
|
} else {
|
|
743
1038
|
config.MULTIVERSE_ENABLE_EMBEDDINGS = "false";
|
|
744
1039
|
}
|
|
745
1040
|
}
|
|
746
1041
|
|
|
747
1042
|
// ── Page 5: Docker Image & Finish ────────────────────────────────
|
|
748
|
-
showPageHeader(5, "Docker Image & Finish", io);
|
|
1043
|
+
showPageHeader(5, "Docker Image & Finish", io, wizardContext);
|
|
749
1044
|
if (!flags["image-tag"]) {
|
|
750
1045
|
config.MULTIVERSE_IMAGE_TAG = await prompt(rl, "Multiverse image tag", config.MULTIVERSE_IMAGE_TAG);
|
|
751
1046
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
io.log("");
|
|
1047
|
+
showSetupSummary(config, paths.envPath, io);
|
|
1048
|
+
const confirm = await promptMenu(rl, "Save this configuration and start Multiverse?", yesNoOptions(), "yes", overrides);
|
|
1049
|
+
if (confirm !== "yes") {
|
|
1050
|
+
throw new Error("setup cancelled; existing configuration was not changed");
|
|
1051
|
+
}
|
|
758
1052
|
} finally {
|
|
759
1053
|
rl.close();
|
|
760
1054
|
}
|
|
@@ -764,17 +1058,20 @@ export function printHelp(log = console.log) {
|
|
|
764
1058
|
log(`git-multiverse
|
|
765
1059
|
|
|
766
1060
|
Commands:
|
|
767
|
-
git-multiverse setup [--non-interactive] [--
|
|
1061
|
+
git-multiverse setup [--non-interactive] [--runtime docker|native] [--db-mode local|remote]
|
|
768
1062
|
[--workspace /absolute/path]
|
|
769
1063
|
[--admin-password <password>]
|
|
770
1064
|
[--neo4j-uri <uri>] [--neo4j-user <user>] [--neo4j-password <password>] [--neo4j-database <db>]
|
|
771
1065
|
[--enable-ai] [--llm-base-url <url>] [--llm-model <name>] [--llm-key <key>]
|
|
772
1066
|
[--enable-embeddings] [--embedding-base-url <url>] [--embedding-model <name>] [--embedding-key <key>]
|
|
773
1067
|
[--image-tag <tag>]
|
|
774
|
-
git-multiverse
|
|
775
|
-
git-multiverse
|
|
1068
|
+
git-multiverse start
|
|
1069
|
+
git-multiverse stop
|
|
776
1070
|
git-multiverse status
|
|
1071
|
+
git-multiverse logs [--tail 200]
|
|
1072
|
+
git-multiverse verify
|
|
777
1073
|
git-multiverse doctor
|
|
1074
|
+
git-multiverse uninstall
|
|
778
1075
|
git-multiverse mcp-setup [--editor claude|cursor|codex|opencode|windsurf]
|
|
779
1076
|
[--base-url <url>] [--mcp-key <key>]`);
|
|
780
1077
|
}
|
|
@@ -794,6 +1091,17 @@ function hasAny(flags, keys) {
|
|
|
794
1091
|
return keys.some((key) => Object.prototype.hasOwnProperty.call(flags, key));
|
|
795
1092
|
}
|
|
796
1093
|
|
|
1094
|
+
function ensureRuntimeSupported(config, overrides = {}) {
|
|
1095
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
1096
|
+
const resolved = resolveNativeBinary(overrides);
|
|
1097
|
+
if (!resolved.ok) throw new Error(nativeRuntimeUnavailableMessage(resolved.message));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function nativeRuntimeUnavailableMessage(detail = "") {
|
|
1102
|
+
return `Native + Remote Database runtime is not available in this package yet. Use Docker Compose mode for now, or install a packaged multiverse-api binary. ${detail}`.trim();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
797
1105
|
function normalizeNeo4jMode(value) {
|
|
798
1106
|
if (value !== "managed" && value !== "external") {
|
|
799
1107
|
throw new Error("Use --neo4j-mode managed|external.");
|
|
@@ -801,6 +1109,33 @@ function normalizeNeo4jMode(value) {
|
|
|
801
1109
|
return value;
|
|
802
1110
|
}
|
|
803
1111
|
|
|
1112
|
+
function normalizeRuntimeMode(value) {
|
|
1113
|
+
if (value === "non-docker") return "native";
|
|
1114
|
+
if (value !== "docker" && value !== "native") {
|
|
1115
|
+
throw new Error("Use --runtime docker|native.");
|
|
1116
|
+
}
|
|
1117
|
+
return value;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function normalizeDbMode(value) {
|
|
1121
|
+
if (value !== "local" && value !== "remote") {
|
|
1122
|
+
throw new Error("Use --db-mode local|remote.");
|
|
1123
|
+
}
|
|
1124
|
+
return value;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function legacyNeo4jModeToDbMode(value) {
|
|
1128
|
+
if (!value) return "";
|
|
1129
|
+
return normalizeNeo4jMode(value) === "managed" ? "local" : "remote";
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function yesNoOptions() {
|
|
1133
|
+
return [
|
|
1134
|
+
{ name: "No", value: "no" },
|
|
1135
|
+
{ name: "Yes", value: "yes" }
|
|
1136
|
+
];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
804
1139
|
function quoteEnv(value) {
|
|
805
1140
|
const stringValue = String(value);
|
|
806
1141
|
return /^[A-Za-z0-9_./:@-]*$/.test(stringValue) ? stringValue : JSON.stringify(stringValue);
|
|
@@ -848,16 +1183,59 @@ async function prompt(rl, label, defaultValue) {
|
|
|
848
1183
|
return answer.trim() || defaultValue || "";
|
|
849
1184
|
}
|
|
850
1185
|
|
|
851
|
-
async function
|
|
852
|
-
|
|
853
|
-
|
|
1186
|
+
async function promptSecret(rl, label, currentValue = "", options = {}) {
|
|
1187
|
+
for (;;) {
|
|
1188
|
+
const suffix = currentValue ? ` [${colors.cyan}configured, press Enter to keep${colors.reset}]` : "";
|
|
1189
|
+
const answer = await rl.question(`${colors.bold}${colors.white}? ${label}${colors.reset}${suffix}: `);
|
|
1190
|
+
const trimmed = answer.trim();
|
|
1191
|
+
if (trimmed) return trimmed;
|
|
1192
|
+
if (currentValue) return currentValue;
|
|
1193
|
+
if (!options.required) return "";
|
|
1194
|
+
console.log(`${colors.yellow}${label} is required.${colors.reset}`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function promptRequired(rl, label) {
|
|
1199
|
+
for (;;) {
|
|
1200
|
+
const answer = await prompt(rl, label, "");
|
|
1201
|
+
if (answer.trim()) return answer.trim();
|
|
1202
|
+
console.log(`${colors.yellow}${label} is required.${colors.reset}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function showSetupSummary(config, envPath, io) {
|
|
1207
|
+
console.clear();
|
|
1208
|
+
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
1209
|
+
io.log(`${colors.bold}${colors.cyan} Review Multiverse Setup${colors.reset}`);
|
|
1210
|
+
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
1211
|
+
io.log(`Config: ${colors.cyan}${envPath}${colors.reset}`);
|
|
1212
|
+
io.log(`Runtime: ${colors.cyan}${config.MULTIVERSE_RUNTIME_MODE}${colors.reset}`);
|
|
1213
|
+
io.log(`Database mode: ${colors.cyan}${config.MULTIVERSE_DB_MODE}${colors.reset}`);
|
|
1214
|
+
io.log(`Neo4j URI: ${colors.cyan}${config.MULTIVERSE_NEO4J_URI || "<not set>"}${colors.reset}`);
|
|
1215
|
+
io.log(`Neo4j user: ${colors.cyan}${config.MULTIVERSE_NEO4J_USER || "<not set>"}${colors.reset}`);
|
|
1216
|
+
io.log(`Neo4j password: ${colors.cyan}${config.MULTIVERSE_NEO4J_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
|
|
1217
|
+
io.log(`Workspace path: ${colors.cyan}${config.MULTIVERSE_HOST_WORKSPACE_DIR}${colors.reset}`);
|
|
1218
|
+
io.log(`Admin user: ${colors.cyan}${config.MULTIVERSE_ADMIN_USER}${colors.reset}`);
|
|
1219
|
+
io.log(`Admin password: ${colors.cyan}${config.MULTIVERSE_ADMIN_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
|
|
1220
|
+
io.log(`AI provider: ${colors.cyan}${config.MULTIVERSE_ENABLE_AI === "true" ? "enabled" : "disabled"}${colors.reset}`);
|
|
1221
|
+
io.log(`Embeddings: ${colors.cyan}${config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "enabled" : "disabled"}${colors.reset}`);
|
|
1222
|
+
io.log("");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function promptMenu(rl, label, options, defaultValue, overrides = {}) {
|
|
1226
|
+
const stdin = overrides.stdin || process.stdin;
|
|
1227
|
+
const stdout = overrides.stdout || process.stdout;
|
|
1228
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
1229
|
+
const defaultOption = options.find((option) => option.name === defaultValue || option.value === defaultValue) || options[0];
|
|
1230
|
+
const answer = await prompt(rl, label, defaultOption.value);
|
|
1231
|
+
return options.find((option) => option.value === answer || option.name === answer)?.value || defaultOption.value;
|
|
1232
|
+
}
|
|
854
1233
|
|
|
855
1234
|
rl.pause(); // Pause readline interface to prevent double echoing
|
|
856
1235
|
|
|
857
1236
|
readlineModule.emitKeypressEvents(stdin);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
1237
|
+
stdin.resume();
|
|
1238
|
+
stdin.setRawMode(true);
|
|
861
1239
|
|
|
862
1240
|
let activeIdx = options.findIndex(o => o.name === defaultValue || o.value === defaultValue);
|
|
863
1241
|
if (activeIdx === -1) activeIdx = 0;
|
|
@@ -867,7 +1245,7 @@ async function promptMenu(rl, label, options, defaultValue) {
|
|
|
867
1245
|
stdout.write(`${colors.bold}${colors.magenta}? ${label}${colors.reset}\n`);
|
|
868
1246
|
for (let i = 0; i < options.length; i++) {
|
|
869
1247
|
if (i === activeIdx) {
|
|
870
|
-
stdout.write(` ${colors.cyan}
|
|
1248
|
+
stdout.write(` ${colors.cyan}> ${options[i].name}${colors.reset}\n`);
|
|
871
1249
|
} else {
|
|
872
1250
|
stdout.write(` ${options[i].name}\n`);
|
|
873
1251
|
}
|
|
@@ -900,9 +1278,7 @@ async function promptMenu(rl, label, options, defaultValue) {
|
|
|
900
1278
|
} else if (key && (key.name === "return" || key.name === "enter")) {
|
|
901
1279
|
stdout.write("\x1B[?25h"); // Show cursor
|
|
902
1280
|
stdin.removeListener("keypress", onKeypress);
|
|
903
|
-
|
|
904
|
-
stdin.setRawMode(false);
|
|
905
|
-
}
|
|
1281
|
+
stdin.setRawMode(false);
|
|
906
1282
|
stdout.write(`\n`);
|
|
907
1283
|
rl.resume(); // Resume readline interface
|
|
908
1284
|
resolve(options[activeIdx].value);
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-multiverse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Clone-free Multiverse Docker Hub launcher.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"git-multiverse": "./bin/git-multiverse.mjs"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"build:native": "node ./scripts/build-native.mjs",
|
|
10
11
|
"test": "node --test",
|
|
11
12
|
"doctor": "node ./bin/git-multiverse.mjs doctor"
|
|
12
13
|
},
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
buildMcpClientConfig,
|
|
9
|
-
cmdDoctor,
|
|
10
|
-
cmdMcpSetup,
|
|
11
|
-
cmdSetup,
|
|
12
|
-
createSetupConfig,
|
|
13
|
-
getStatePaths,
|
|
14
|
-
parseEnvFile,
|
|
15
|
-
renderComposeFile,
|
|
16
|
-
renderMcpClientConfig,
|
|
17
|
-
writeStateFiles
|
|
18
|
-
} from "../lib/app.mjs";
|
|
19
|
-
|
|
20
|
-
test("writeStateFiles writes env mode 0600 with expected keys", () => {
|
|
21
|
-
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
|
|
22
|
-
const config = createSetupConfig({
|
|
23
|
-
workspace: "/tmp/repos",
|
|
24
|
-
"admin-password": "admin-secret",
|
|
25
|
-
"neo4j-password": "neo4j-secret",
|
|
26
|
-
"image-tag": "test"
|
|
27
|
-
}, { homeDir });
|
|
28
|
-
|
|
29
|
-
const paths = writeStateFiles(config, { homeDir });
|
|
30
|
-
const envText = fs.readFileSync(paths.envPath, "utf8");
|
|
31
|
-
const mode = fs.statSync(paths.envPath).mode & 0o777;
|
|
32
|
-
|
|
33
|
-
if (os.platform() !== "win32") {
|
|
34
|
-
assert.equal(mode, 0o600);
|
|
35
|
-
}
|
|
36
|
-
assert.match(envText, /^MULTIVERSE_ADMIN_PASSWORD=admin-secret/m);
|
|
37
|
-
assert.match(envText, /^MULTIVERSE_MCP_ALLOW_ANONYMOUS=false/m);
|
|
38
|
-
assert.match(envText, /^MULTIVERSE_IMAGE_TAG=test/m);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("createSetupConfig correctly pre-fills and respects existingConfig", () => {
|
|
42
|
-
const existing = {
|
|
43
|
-
MULTIVERSE_HOST_WORKSPACE_DIR: "/my/previous/workspace",
|
|
44
|
-
MULTIVERSE_NEO4J_MODE: "external",
|
|
45
|
-
MULTIVERSE_ADMIN_PASSWORD: "my-old-pass",
|
|
46
|
-
MULTIVERSE_NEO4J_PASSWORD: "neo-old-pass",
|
|
47
|
-
MULTIVERSE_ENABLE_AI: "true",
|
|
48
|
-
NINEROUTER_API_BASE_URL: "https://api.custom-ai.com/v1",
|
|
49
|
-
NINEROUTER_MODEL_NAME: "custom-gpt-5",
|
|
50
|
-
NINEROUTER_API_KEY: "custom-key",
|
|
51
|
-
MULTIVERSE_ENABLE_EMBEDDINGS: "true",
|
|
52
|
-
EMBEDDING_API_BASE_URL: "https://api.custom-embed.com/v1",
|
|
53
|
-
EMBEDDING_MODEL_NAME: "custom-embed-3",
|
|
54
|
-
EMBEDDING_API_KEY: "embed-key",
|
|
55
|
-
MULTIVERSE_NEO4J_URI: "neo4j://old-uri:7687",
|
|
56
|
-
MULTIVERSE_NEO4J_USER: "old-user"
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const config = createSetupConfig({}, {}, existing);
|
|
60
|
-
|
|
61
|
-
assert.equal(config.MULTIVERSE_HOST_WORKSPACE_DIR, "/my/previous/workspace");
|
|
62
|
-
assert.equal(config.MULTIVERSE_NEO4J_MODE, "external");
|
|
63
|
-
assert.equal(config.MULTIVERSE_ADMIN_PASSWORD, "my-old-pass");
|
|
64
|
-
assert.equal(config.MULTIVERSE_NEO4J_PASSWORD, "neo-old-pass");
|
|
65
|
-
assert.equal(config.MULTIVERSE_ENABLE_AI, "true");
|
|
66
|
-
assert.equal(config.NINEROUTER_API_BASE_URL, "https://api.custom-ai.com/v1");
|
|
67
|
-
assert.equal(config.NINEROUTER_MODEL_NAME, "custom-gpt-5");
|
|
68
|
-
assert.equal(config.NINEROUTER_API_KEY, "custom-key");
|
|
69
|
-
assert.equal(config.MULTIVERSE_ENABLE_EMBEDDINGS, "true");
|
|
70
|
-
assert.equal(config.EMBEDDING_API_BASE_URL, "https://api.custom-embed.com/v1");
|
|
71
|
-
assert.equal(config.EMBEDDING_MODEL_NAME, "custom-embed-3");
|
|
72
|
-
assert.equal(config.EMBEDDING_API_KEY, "embed-key");
|
|
73
|
-
assert.equal(config.MULTIVERSE_NEO4J_URI, "neo4j://old-uri:7687");
|
|
74
|
-
assert.equal(config.MULTIVERSE_NEO4J_USER, "old-user");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("renderComposeFile references Docker Hub images and localhost binds", () => {
|
|
78
|
-
const config = createSetupConfig({
|
|
79
|
-
workspace: "/tmp/repos",
|
|
80
|
-
"admin-password": "admin-secret",
|
|
81
|
-
"neo4j-password": "neo4j-secret"
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const compose = renderComposeFile(config);
|
|
85
|
-
assert.match(compose, /image: anhdt5\/git-multiverse:latest/);
|
|
86
|
-
assert.match(compose, /image: neo4j:5\.26-community/);
|
|
87
|
-
assert.match(compose, /127\.0\.0\.1:18081:18081/);
|
|
88
|
-
assert.match(compose, /127\.0\.0\.1:7474:7474/);
|
|
89
|
-
assert.match(compose, /127\.0\.0\.1:7687:7687/);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("non-interactive setup produces deterministic files in temp HOME", async () => {
|
|
93
|
-
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
|
|
94
|
-
const output = [];
|
|
95
|
-
|
|
96
|
-
await cmdSetup({
|
|
97
|
-
"non-interactive": true,
|
|
98
|
-
"neo4j-mode": "external",
|
|
99
|
-
workspace: "/tmp/repos",
|
|
100
|
-
"admin-password": "admin-secret",
|
|
101
|
-
"neo4j-uri": "neo4j://db.example:7687",
|
|
102
|
-
"neo4j-user": "neo4j",
|
|
103
|
-
"neo4j-password": "neo4j-secret",
|
|
104
|
-
"neo4j-database": "neo4j",
|
|
105
|
-
"image-tag": "1.2.3"
|
|
106
|
-
}, { homeDir, log: (line) => output.push(line) });
|
|
107
|
-
|
|
108
|
-
const { envPath, composePath } = getStatePaths({ homeDir });
|
|
109
|
-
const envText = fs.readFileSync(envPath, "utf8");
|
|
110
|
-
const composeText = fs.readFileSync(composePath, "utf8");
|
|
111
|
-
const parsed = parseEnvFile(envText);
|
|
112
|
-
|
|
113
|
-
assert.equal(parsed.MULTIVERSE_NEO4J_MODE, "external");
|
|
114
|
-
assert.equal(parsed.MULTIVERSE_NEO4J_URI, "neo4j://db.example:7687");
|
|
115
|
-
assert.match(composeText, /image: anhdt5\/git-multiverse:1\.2\.3/);
|
|
116
|
-
assert.doesNotMatch(composeText, /neo4j:5\.26-community/);
|
|
117
|
-
assert.ok(output.some((line) => String(line).includes(envPath)));
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("doctor reports missing docker gracefully", async () => {
|
|
121
|
-
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
|
|
122
|
-
const config = createSetupConfig({
|
|
123
|
-
workspace: "/tmp/repos",
|
|
124
|
-
"admin-password": "admin-secret",
|
|
125
|
-
"neo4j-password": "neo4j-secret"
|
|
126
|
-
}, { homeDir });
|
|
127
|
-
writeStateFiles(config, { homeDir });
|
|
128
|
-
|
|
129
|
-
const lines = [];
|
|
130
|
-
let exitCode = 0;
|
|
131
|
-
await cmdDoctor({
|
|
132
|
-
homeDir,
|
|
133
|
-
log: (line) => lines.push(line),
|
|
134
|
-
exit: (code) => {
|
|
135
|
-
exitCode = code;
|
|
136
|
-
},
|
|
137
|
-
spawnSyncImpl: () => ({ status: 1, stderr: "not found" })
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
assert.equal(exitCode, 1);
|
|
141
|
-
assert.ok(lines.some((line) => String(line).includes("FAIL docker: missing docker CLI")));
|
|
142
|
-
assert.ok(lines.some((line) => String(line).includes("FAIL docker compose: missing docker compose plugin")));
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("buildMcpClientConfig targets the /mcp endpoint with a bearer header", () => {
|
|
146
|
-
const cursor = buildMcpClientConfig("cursor", "http://127.0.0.1:18081/", "key-123");
|
|
147
|
-
assert.equal(cursor.kind, "json");
|
|
148
|
-
assert.equal(cursor.config.mcpServers.multiverse.url, "http://127.0.0.1:18081/mcp");
|
|
149
|
-
assert.equal(cursor.config.mcpServers.multiverse.headers.Authorization, "Bearer key-123");
|
|
150
|
-
|
|
151
|
-
const opencode = buildMcpClientConfig("opencode", "http://127.0.0.1:18081", "key-123");
|
|
152
|
-
assert.equal(opencode.config.mcp.multiverse.type, "remote");
|
|
153
|
-
assert.equal(opencode.config.mcp.multiverse.url, "http://127.0.0.1:18081/mcp");
|
|
154
|
-
|
|
155
|
-
const claude = buildMcpClientConfig("claude", "http://127.0.0.1:18081", "key-123");
|
|
156
|
-
assert.equal(claude.kind, "shell");
|
|
157
|
-
assert.match(claude.command, /claude mcp add --transport http multiverse http:\/\/127\.0\.0\.1:18081\/mcp/);
|
|
158
|
-
assert.match(claude.command, /Bearer key-123/);
|
|
159
|
-
|
|
160
|
-
const codex = buildMcpClientConfig("codex", "http://127.0.0.1:18081", "key-123");
|
|
161
|
-
assert.equal(codex.kind, "toml");
|
|
162
|
-
assert.match(codex.config, /\[mcp_servers\.multiverse\]/);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("buildMcpClientConfig uses placeholder when key is missing and rejects unknown editors", () => {
|
|
166
|
-
const snippet = renderMcpClientConfig("windsurf", "http://127.0.0.1:18081", "");
|
|
167
|
-
assert.match(snippet, /Bearer <MCP_KEY>/);
|
|
168
|
-
assert.throws(() => buildMcpClientConfig("emacs", "http://127.0.0.1:18081", "k"), /Unknown editor/);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("cmdMcpSetup prints a snippet for every supported editor", async () => {
|
|
172
|
-
const lines = [];
|
|
173
|
-
await cmdMcpSetup({ "mcp-key": "key-123" }, { log: (line) => lines.push(line) });
|
|
174
|
-
const text = lines.join("\n");
|
|
175
|
-
for (const editor of ["claude", "cursor", "codex", "opencode", "windsurf"]) {
|
|
176
|
-
assert.ok(text.includes(`# ${editor}`), `expected snippet for ${editor}`);
|
|
177
|
-
}
|
|
178
|
-
assert.ok(text.includes("Bearer key-123"));
|
|
179
|
-
});
|