git-multiverse 0.1.1 → 0.1.2
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 +473 -102
- 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,69 @@ 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
|
-
config.
|
|
849
|
+
showPageHeader(1, "System & Workspace", io, wizardContext);
|
|
850
|
+
if (!flags.runtime) {
|
|
851
|
+
config.MULTIVERSE_RUNTIME_MODE = await promptMenu(rl, "Select runtime mode:", [
|
|
852
|
+
{ name: "Docker Compose (recommended)", value: "docker" }
|
|
853
|
+
], "docker", overrides);
|
|
854
|
+
}
|
|
855
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
856
|
+
config.MULTIVERSE_DB_MODE = "remote";
|
|
857
|
+
config.MULTIVERSE_NEO4J_MODE = "external";
|
|
858
|
+
} else if (!flags["db-mode"] && !flags["neo4j-mode"]) {
|
|
859
|
+
config.MULTIVERSE_DB_MODE = await promptMenu(rl, "Select database mode:", [
|
|
860
|
+
{ name: "Local Neo4j container", value: "local" },
|
|
861
|
+
{ name: "Remote database", value: "remote" }
|
|
862
|
+
], config.MULTIVERSE_DB_MODE, overrides);
|
|
863
|
+
config.MULTIVERSE_NEO4J_MODE = config.MULTIVERSE_DB_MODE === "local" ? "managed" : "external";
|
|
572
864
|
}
|
|
573
865
|
if (!flags.workspace) {
|
|
574
866
|
config.MULTIVERSE_HOST_WORKSPACE_DIR = await prompt(rl, "Workspace host path", config.MULTIVERSE_HOST_WORKSPACE_DIR);
|
|
575
867
|
}
|
|
576
868
|
|
|
577
869
|
// ── Page 2: Security & Credentials ──────────────────────────────
|
|
578
|
-
showPageHeader(2, "Security & Credentials", io);
|
|
870
|
+
showPageHeader(2, "Security & Credentials", io, wizardContext);
|
|
579
871
|
if (!flags["admin-password"]) {
|
|
580
|
-
config.MULTIVERSE_ADMIN_PASSWORD = await
|
|
872
|
+
config.MULTIVERSE_ADMIN_PASSWORD = await promptSecret(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD, { required: true });
|
|
581
873
|
}
|
|
582
|
-
if (config.
|
|
874
|
+
if (config.MULTIVERSE_DB_MODE === "local") {
|
|
583
875
|
if (!flags["neo4j-password"]) {
|
|
584
|
-
config.MULTIVERSE_NEO4J_PASSWORD = await
|
|
876
|
+
config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Local Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
|
|
585
877
|
}
|
|
586
878
|
} else {
|
|
587
879
|
if (!flags["neo4j-uri"]) config.MULTIVERSE_NEO4J_URI = await prompt(rl, "Neo4j URI", config.MULTIVERSE_NEO4J_URI);
|
|
588
880
|
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
|
|
881
|
+
if (!flags["neo4j-password"]) config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Remote Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
|
|
590
882
|
if (!flags["neo4j-database"]) config.MULTIVERSE_NEO4J_DATABASE = await prompt(rl, "Neo4j database", config.MULTIVERSE_NEO4J_DATABASE);
|
|
591
883
|
}
|
|
592
884
|
|
|
593
885
|
// ── Page 3: AI Provider (LLM) ───────────────────────────────────
|
|
594
|
-
showPageHeader(3, "AI Provider (LLM)", io);
|
|
886
|
+
showPageHeader(3, "AI Provider (LLM)", io, wizardContext);
|
|
595
887
|
if (!hasAny(flags, ["enable-ai", "llm-base-url", "llm-model", "llm-key"])) {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
if (/^y(es)?$/i.test(answer)) {
|
|
888
|
+
const answer = await promptMenu(rl, "Configure AI provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_AI === "true" ? "yes" : "no", overrides);
|
|
889
|
+
if (answer === "yes") {
|
|
599
890
|
config.MULTIVERSE_ENABLE_AI = "true";
|
|
600
891
|
io.log("");
|
|
601
892
|
const providerOptions = [
|
|
@@ -620,7 +911,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
620
911
|
else defaultProvider = "custom";
|
|
621
912
|
}
|
|
622
913
|
|
|
623
|
-
const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider);
|
|
914
|
+
const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider, overrides);
|
|
624
915
|
io.log("");
|
|
625
916
|
|
|
626
917
|
let baseUrl = "";
|
|
@@ -671,18 +962,17 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
671
962
|
|
|
672
963
|
config.NINEROUTER_API_BASE_URL = baseUrl;
|
|
673
964
|
config.NINEROUTER_MODEL_NAME = modelName;
|
|
674
|
-
config.NINEROUTER_API_KEY = await
|
|
965
|
+
config.NINEROUTER_API_KEY = await promptSecret(rl, "LLM API key", config.NINEROUTER_API_KEY);
|
|
675
966
|
} else {
|
|
676
967
|
config.MULTIVERSE_ENABLE_AI = "false";
|
|
677
968
|
}
|
|
678
969
|
}
|
|
679
970
|
|
|
680
971
|
// ── Page 4: Vector Embeddings ───────────────────────────────────
|
|
681
|
-
showPageHeader(4, "Vector Embeddings", io);
|
|
972
|
+
showPageHeader(4, "Vector Embeddings", io, wizardContext);
|
|
682
973
|
if (!hasAny(flags, ["enable-embeddings", "embedding-base-url", "embedding-model", "embedding-key"])) {
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
if (/^y(es)?$/i.test(answer)) {
|
|
974
|
+
const answer = await promptMenu(rl, "Configure embedding provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "yes" : "no", overrides);
|
|
975
|
+
if (answer === "yes") {
|
|
686
976
|
config.MULTIVERSE_ENABLE_EMBEDDINGS = "true";
|
|
687
977
|
io.log("");
|
|
688
978
|
const embedOptions = [
|
|
@@ -699,7 +989,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
699
989
|
else defaultEmbed = "custom";
|
|
700
990
|
}
|
|
701
991
|
|
|
702
|
-
const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed);
|
|
992
|
+
const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed, overrides);
|
|
703
993
|
io.log("");
|
|
704
994
|
|
|
705
995
|
let baseUrl = "";
|
|
@@ -738,23 +1028,22 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
|
|
|
738
1028
|
|
|
739
1029
|
config.EMBEDDING_API_BASE_URL = baseUrl;
|
|
740
1030
|
config.EMBEDDING_MODEL_NAME = modelName;
|
|
741
|
-
config.EMBEDDING_API_KEY = await
|
|
1031
|
+
config.EMBEDDING_API_KEY = await promptSecret(rl, "Embedding API key", config.EMBEDDING_API_KEY);
|
|
742
1032
|
} else {
|
|
743
1033
|
config.MULTIVERSE_ENABLE_EMBEDDINGS = "false";
|
|
744
1034
|
}
|
|
745
1035
|
}
|
|
746
1036
|
|
|
747
1037
|
// ── Page 5: Docker Image & Finish ────────────────────────────────
|
|
748
|
-
showPageHeader(5, "Docker Image & Finish", io);
|
|
1038
|
+
showPageHeader(5, "Docker Image & Finish", io, wizardContext);
|
|
749
1039
|
if (!flags["image-tag"]) {
|
|
750
1040
|
config.MULTIVERSE_IMAGE_TAG = await prompt(rl, "Multiverse image tag", config.MULTIVERSE_IMAGE_TAG);
|
|
751
1041
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
io.log("");
|
|
1042
|
+
showSetupSummary(config, paths.envPath, io);
|
|
1043
|
+
const confirm = await promptMenu(rl, "Save this configuration and start Multiverse?", yesNoOptions(), "yes", overrides);
|
|
1044
|
+
if (confirm !== "yes") {
|
|
1045
|
+
throw new Error("setup cancelled; existing configuration was not changed");
|
|
1046
|
+
}
|
|
758
1047
|
} finally {
|
|
759
1048
|
rl.close();
|
|
760
1049
|
}
|
|
@@ -764,17 +1053,20 @@ export function printHelp(log = console.log) {
|
|
|
764
1053
|
log(`git-multiverse
|
|
765
1054
|
|
|
766
1055
|
Commands:
|
|
767
|
-
git-multiverse setup [--non-interactive] [--
|
|
1056
|
+
git-multiverse setup [--non-interactive] [--runtime docker|native] [--db-mode local|remote]
|
|
768
1057
|
[--workspace /absolute/path]
|
|
769
1058
|
[--admin-password <password>]
|
|
770
1059
|
[--neo4j-uri <uri>] [--neo4j-user <user>] [--neo4j-password <password>] [--neo4j-database <db>]
|
|
771
1060
|
[--enable-ai] [--llm-base-url <url>] [--llm-model <name>] [--llm-key <key>]
|
|
772
1061
|
[--enable-embeddings] [--embedding-base-url <url>] [--embedding-model <name>] [--embedding-key <key>]
|
|
773
1062
|
[--image-tag <tag>]
|
|
774
|
-
git-multiverse
|
|
775
|
-
git-multiverse
|
|
1063
|
+
git-multiverse start
|
|
1064
|
+
git-multiverse stop
|
|
776
1065
|
git-multiverse status
|
|
1066
|
+
git-multiverse logs [--tail 200]
|
|
1067
|
+
git-multiverse verify
|
|
777
1068
|
git-multiverse doctor
|
|
1069
|
+
git-multiverse uninstall
|
|
778
1070
|
git-multiverse mcp-setup [--editor claude|cursor|codex|opencode|windsurf]
|
|
779
1071
|
[--base-url <url>] [--mcp-key <key>]`);
|
|
780
1072
|
}
|
|
@@ -794,6 +1086,17 @@ function hasAny(flags, keys) {
|
|
|
794
1086
|
return keys.some((key) => Object.prototype.hasOwnProperty.call(flags, key));
|
|
795
1087
|
}
|
|
796
1088
|
|
|
1089
|
+
function ensureRuntimeSupported(config, overrides = {}) {
|
|
1090
|
+
if (config.MULTIVERSE_RUNTIME_MODE === "native") {
|
|
1091
|
+
const resolved = resolveNativeBinary(overrides);
|
|
1092
|
+
if (!resolved.ok) throw new Error(nativeRuntimeUnavailableMessage(resolved.message));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function nativeRuntimeUnavailableMessage(detail = "") {
|
|
1097
|
+
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();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
797
1100
|
function normalizeNeo4jMode(value) {
|
|
798
1101
|
if (value !== "managed" && value !== "external") {
|
|
799
1102
|
throw new Error("Use --neo4j-mode managed|external.");
|
|
@@ -801,6 +1104,33 @@ function normalizeNeo4jMode(value) {
|
|
|
801
1104
|
return value;
|
|
802
1105
|
}
|
|
803
1106
|
|
|
1107
|
+
function normalizeRuntimeMode(value) {
|
|
1108
|
+
if (value === "non-docker") return "native";
|
|
1109
|
+
if (value !== "docker" && value !== "native") {
|
|
1110
|
+
throw new Error("Use --runtime docker|native.");
|
|
1111
|
+
}
|
|
1112
|
+
return value;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function normalizeDbMode(value) {
|
|
1116
|
+
if (value !== "local" && value !== "remote") {
|
|
1117
|
+
throw new Error("Use --db-mode local|remote.");
|
|
1118
|
+
}
|
|
1119
|
+
return value;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function legacyNeo4jModeToDbMode(value) {
|
|
1123
|
+
if (!value) return "";
|
|
1124
|
+
return normalizeNeo4jMode(value) === "managed" ? "local" : "remote";
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function yesNoOptions() {
|
|
1128
|
+
return [
|
|
1129
|
+
{ name: "No", value: "no" },
|
|
1130
|
+
{ name: "Yes", value: "yes" }
|
|
1131
|
+
];
|
|
1132
|
+
}
|
|
1133
|
+
|
|
804
1134
|
function quoteEnv(value) {
|
|
805
1135
|
const stringValue = String(value);
|
|
806
1136
|
return /^[A-Za-z0-9_./:@-]*$/.test(stringValue) ? stringValue : JSON.stringify(stringValue);
|
|
@@ -848,16 +1178,59 @@ async function prompt(rl, label, defaultValue) {
|
|
|
848
1178
|
return answer.trim() || defaultValue || "";
|
|
849
1179
|
}
|
|
850
1180
|
|
|
851
|
-
async function
|
|
852
|
-
|
|
853
|
-
|
|
1181
|
+
async function promptSecret(rl, label, currentValue = "", options = {}) {
|
|
1182
|
+
for (;;) {
|
|
1183
|
+
const suffix = currentValue ? ` [${colors.cyan}configured, press Enter to keep${colors.reset}]` : "";
|
|
1184
|
+
const answer = await rl.question(`${colors.bold}${colors.white}? ${label}${colors.reset}${suffix}: `);
|
|
1185
|
+
const trimmed = answer.trim();
|
|
1186
|
+
if (trimmed) return trimmed;
|
|
1187
|
+
if (currentValue) return currentValue;
|
|
1188
|
+
if (!options.required) return "";
|
|
1189
|
+
console.log(`${colors.yellow}${label} is required.${colors.reset}`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function promptRequired(rl, label) {
|
|
1194
|
+
for (;;) {
|
|
1195
|
+
const answer = await prompt(rl, label, "");
|
|
1196
|
+
if (answer.trim()) return answer.trim();
|
|
1197
|
+
console.log(`${colors.yellow}${label} is required.${colors.reset}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function showSetupSummary(config, envPath, io) {
|
|
1202
|
+
console.clear();
|
|
1203
|
+
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
1204
|
+
io.log(`${colors.bold}${colors.cyan} Review Multiverse Setup${colors.reset}`);
|
|
1205
|
+
io.log(`${colors.cyan}==================================================${colors.reset}`);
|
|
1206
|
+
io.log(`Config: ${colors.cyan}${envPath}${colors.reset}`);
|
|
1207
|
+
io.log(`Runtime: ${colors.cyan}${config.MULTIVERSE_RUNTIME_MODE}${colors.reset}`);
|
|
1208
|
+
io.log(`Database mode: ${colors.cyan}${config.MULTIVERSE_DB_MODE}${colors.reset}`);
|
|
1209
|
+
io.log(`Neo4j URI: ${colors.cyan}${config.MULTIVERSE_NEO4J_URI || "<not set>"}${colors.reset}`);
|
|
1210
|
+
io.log(`Neo4j user: ${colors.cyan}${config.MULTIVERSE_NEO4J_USER || "<not set>"}${colors.reset}`);
|
|
1211
|
+
io.log(`Neo4j password: ${colors.cyan}${config.MULTIVERSE_NEO4J_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
|
|
1212
|
+
io.log(`Workspace path: ${colors.cyan}${config.MULTIVERSE_HOST_WORKSPACE_DIR}${colors.reset}`);
|
|
1213
|
+
io.log(`Admin user: ${colors.cyan}${config.MULTIVERSE_ADMIN_USER}${colors.reset}`);
|
|
1214
|
+
io.log(`Admin password: ${colors.cyan}${config.MULTIVERSE_ADMIN_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
|
|
1215
|
+
io.log(`AI provider: ${colors.cyan}${config.MULTIVERSE_ENABLE_AI === "true" ? "enabled" : "disabled"}${colors.reset}`);
|
|
1216
|
+
io.log(`Embeddings: ${colors.cyan}${config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "enabled" : "disabled"}${colors.reset}`);
|
|
1217
|
+
io.log("");
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function promptMenu(rl, label, options, defaultValue, overrides = {}) {
|
|
1221
|
+
const stdin = overrides.stdin || process.stdin;
|
|
1222
|
+
const stdout = overrides.stdout || process.stdout;
|
|
1223
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
1224
|
+
const defaultOption = options.find((option) => option.name === defaultValue || option.value === defaultValue) || options[0];
|
|
1225
|
+
const answer = await prompt(rl, label, defaultOption.value);
|
|
1226
|
+
return options.find((option) => option.value === answer || option.name === answer)?.value || defaultOption.value;
|
|
1227
|
+
}
|
|
854
1228
|
|
|
855
1229
|
rl.pause(); // Pause readline interface to prevent double echoing
|
|
856
1230
|
|
|
857
1231
|
readlineModule.emitKeypressEvents(stdin);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
1232
|
+
stdin.resume();
|
|
1233
|
+
stdin.setRawMode(true);
|
|
861
1234
|
|
|
862
1235
|
let activeIdx = options.findIndex(o => o.name === defaultValue || o.value === defaultValue);
|
|
863
1236
|
if (activeIdx === -1) activeIdx = 0;
|
|
@@ -867,7 +1240,7 @@ async function promptMenu(rl, label, options, defaultValue) {
|
|
|
867
1240
|
stdout.write(`${colors.bold}${colors.magenta}? ${label}${colors.reset}\n`);
|
|
868
1241
|
for (let i = 0; i < options.length; i++) {
|
|
869
1242
|
if (i === activeIdx) {
|
|
870
|
-
stdout.write(` ${colors.cyan}
|
|
1243
|
+
stdout.write(` ${colors.cyan}> ${options[i].name}${colors.reset}\n`);
|
|
871
1244
|
} else {
|
|
872
1245
|
stdout.write(` ${options[i].name}\n`);
|
|
873
1246
|
}
|
|
@@ -900,9 +1273,7 @@ async function promptMenu(rl, label, options, defaultValue) {
|
|
|
900
1273
|
} else if (key && (key.name === "return" || key.name === "enter")) {
|
|
901
1274
|
stdout.write("\x1B[?25h"); // Show cursor
|
|
902
1275
|
stdin.removeListener("keypress", onKeypress);
|
|
903
|
-
|
|
904
|
-
stdin.setRawMode(false);
|
|
905
|
-
}
|
|
1276
|
+
stdin.setRawMode(false);
|
|
906
1277
|
stdout.write(`\n`);
|
|
907
1278
|
rl.resume(); // Resume readline interface
|
|
908
1279
|
resolve(options[activeIdx].value);
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-multiverse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
-
});
|