git-multiverse 0.1.1

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 ADDED
@@ -0,0 +1,51 @@
1
+ # git-multiverse
2
+
3
+ Clone-free launcher for Multiverse.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g git-multiverse
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ git-multiverse setup
15
+ git-multiverse up
16
+ git-multiverse down
17
+ git-multiverse status
18
+ git-multiverse doctor
19
+ ```
20
+
21
+ ## State files
22
+
23
+ - `~/.git-multiverse/` (mode `0700`)
24
+ - `~/.git-multiverse/.env` (mode `0600`)
25
+ - `~/.git-multiverse/docker-compose.yaml`
26
+
27
+ ## Setup flow
28
+
29
+ `git-multiverse setup` writes a standalone compose file that uses:
30
+
31
+ - `anhdt5/git-multiverse:<tag>`
32
+ - `neo4j:5.26-community` when using managed Neo4j
33
+
34
+ Security defaults:
35
+
36
+ - host ports bind to `127.0.0.1`
37
+ - `MULTIVERSE_MCP_ALLOW_ANONYMOUS=false`
38
+ - random admin and Neo4j passwords by default
39
+ - secrets stay only in `~/.git-multiverse/.env`
40
+
41
+ ## Non-interactive example
42
+
43
+ ```bash
44
+ git-multiverse setup \
45
+ --non-interactive \
46
+ --neo4j-mode managed \
47
+ --workspace /workspace/repos \
48
+ --admin-password 'replace-me' \
49
+ --neo4j-password 'replace-me-too' \
50
+ --image-tag latest
51
+ ```
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../lib/app.mjs";
4
+
5
+ await main(process.argv.slice(2));
package/lib/app.mjs ADDED
@@ -0,0 +1,948 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import net from "node:net";
7
+ import readline from "node:readline/promises";
8
+ import readlineModule from "node:readline";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const APP_IMAGE = "anhdt5/git-multiverse";
12
+ export const NEO4J_IMAGE = "neo4j:5.26-community";
13
+ export const DEFAULT_IMAGE_TAG = "latest";
14
+ export const DEFAULT_PORT = "18081";
15
+ export const STATE_DIR_NAME = ".git-multiverse";
16
+
17
+ export const colors = {
18
+ reset: "\x1b[0m",
19
+ cyan: "\x1b[36m",
20
+ green: "\x1b[32m",
21
+ yellow: "\x1b[33m",
22
+ magenta: "\x1b[35m",
23
+ white: "\x1b[37m",
24
+ bold: "\x1b[1m",
25
+ blue: "\x1b[34m"
26
+ };
27
+
28
+ const SECRET_KEYS = new Set([
29
+ "MULTIVERSE_ADMIN_PASSWORD",
30
+ "MULTIVERSE_NEO4J_PASSWORD",
31
+ "NINEROUTER_API_KEY",
32
+ "EMBEDDING_API_KEY"
33
+ ]);
34
+
35
+ export async function main(argv, overrides = {}) {
36
+ const io = createIO(overrides);
37
+ try {
38
+ const { command, flags } = parseArgs(argv);
39
+ switch (command) {
40
+ case "setup":
41
+ await cmdSetup(flags, overrides, io);
42
+ break;
43
+ case "up":
44
+ await cmdUp(overrides, io);
45
+ break;
46
+ case "down":
47
+ await cmdDown(overrides, io);
48
+ break;
49
+ case "status":
50
+ await cmdStatus(overrides, io);
51
+ break;
52
+ case "doctor":
53
+ await cmdDoctor(overrides, io);
54
+ break;
55
+ case "mcp-setup":
56
+ await cmdMcpSetup(flags, overrides, io);
57
+ break;
58
+ case "help":
59
+ case "":
60
+ case undefined:
61
+ printHelp(io.log);
62
+ break;
63
+ default:
64
+ throw new Error(`Unknown command: ${command}`);
65
+ }
66
+ } catch (error) {
67
+ io.error(`error: ${error.message}`);
68
+ if (typeof overrides.exit === "function") {
69
+ overrides.exit(1);
70
+ return;
71
+ }
72
+ process.exitCode = 1;
73
+ }
74
+ }
75
+
76
+ export function parseArgs(argv) {
77
+ const flags = {};
78
+ const positionals = [];
79
+ for (let index = 0; index < argv.length; index += 1) {
80
+ const token = argv[index];
81
+ if (!token.startsWith("--")) {
82
+ positionals.push(token);
83
+ continue;
84
+ }
85
+ const stripped = token.slice(2);
86
+ if (stripped.includes("=")) {
87
+ const [key, value] = stripped.split(/=(.*)/s);
88
+ flags[key] = value;
89
+ continue;
90
+ }
91
+ const next = argv[index + 1];
92
+ if (!next || next.startsWith("--")) {
93
+ flags[stripped] = true;
94
+ continue;
95
+ }
96
+ flags[stripped] = next;
97
+ index += 1;
98
+ }
99
+ return { command: positionals[0], flags };
100
+ }
101
+
102
+ export function getStatePaths(overrides = {}) {
103
+ const homeDir = overrides.homeDir || os.homedir();
104
+ const stateDir = path.join(homeDir, STATE_DIR_NAME);
105
+ return {
106
+ homeDir,
107
+ stateDir,
108
+ envPath: path.join(stateDir, ".env"),
109
+ composePath: path.join(stateDir, "docker-compose.yaml")
110
+ };
111
+ }
112
+
113
+ export function createSetupConfig(flags = {}, overrides = {}, existingConfig = {}) {
114
+ const env = overrides.env || process.env;
115
+ const workspace = stringFlag(flags.workspace) || existingConfig.MULTIVERSE_HOST_WORKSPACE_DIR || path.join(getStatePaths(overrides).homeDir, "workspace", "repos");
116
+ const neo4jMode = normalizeNeo4jMode(
117
+ stringFlag(flags["neo4j-mode"]) ||
118
+ existingConfig.MULTIVERSE_NEO4J_MODE ||
119
+ env.GIT_MULTIVERSE_NEO4J_MODE ||
120
+ "managed"
121
+ );
122
+ const adminPassword = stringFlag(flags["admin-password"]) || existingConfig.MULTIVERSE_ADMIN_PASSWORD;
123
+ const neo4jPassword = stringFlag(flags["neo4j-password"]) || existingConfig.MULTIVERSE_NEO4J_PASSWORD;
124
+ const imageTag = stringFlag(flags["image-tag"]) || existingConfig.MULTIVERSE_IMAGE_TAG || env.GIT_MULTIVERSE_IMAGE_TAG || DEFAULT_IMAGE_TAG;
125
+
126
+ const enableAiFlag = flags["enable-ai"] !== undefined ? (toBool(flags["enable-ai"]) ? "true" : "false") : null;
127
+ const enableEmbeddingsFlag = flags["enable-embeddings"] !== undefined ? (toBool(flags["enable-embeddings"]) ? "true" : "false") : null;
128
+
129
+ const config = {
130
+ MULTIVERSE_HOST: "0.0.0.0",
131
+ MULTIVERSE_PORT: DEFAULT_PORT,
132
+ MULTIVERSE_API_PORT: DEFAULT_PORT,
133
+ MULTIVERSE_ADMIN_USER: "admin",
134
+ MULTIVERSE_ADMIN_PASSWORD: adminPassword || "123456",
135
+ MULTIVERSE_MCP_ALLOW_ANONYMOUS: "false",
136
+ MULTIVERSE_WORKSPACE_DIR: "/workspace/repos",
137
+ MULTIVERSE_HOST_WORKSPACE_DIR: workspace,
138
+ DOCS_STORAGE_PATH: "/workspace/docs",
139
+ MULTIVERSE_ENABLE_AI: enableAiFlag !== null ? enableAiFlag : (existingConfig.MULTIVERSE_ENABLE_AI || "false"),
140
+ NINEROUTER_API_BASE_URL: cleanBaseUrl(stringFlag(flags["llm-base-url"]), "llm") || existingConfig.NINEROUTER_API_BASE_URL || "",
141
+ NINEROUTER_MODEL_NAME: stringFlag(flags["llm-model"]) || existingConfig.NINEROUTER_MODEL_NAME || "",
142
+ NINEROUTER_API_KEY: stringFlag(flags["llm-key"]) || existingConfig.NINEROUTER_API_KEY || "",
143
+ MULTIVERSE_ENABLE_EMBEDDINGS: enableEmbeddingsFlag !== null ? enableEmbeddingsFlag : (existingConfig.MULTIVERSE_ENABLE_EMBEDDINGS || "false"),
144
+ EMBEDDING_API_BASE_URL: cleanBaseUrl(stringFlag(flags["embedding-base-url"]), "embedding") || existingConfig.EMBEDDING_API_BASE_URL || "",
145
+ EMBEDDING_MODEL_NAME: stringFlag(flags["embedding-model"]) || existingConfig.EMBEDDING_MODEL_NAME || "",
146
+ EMBEDDING_API_KEY: stringFlag(flags["embedding-key"]) || existingConfig.EMBEDDING_API_KEY || "",
147
+ MULTIVERSE_IMAGE_TAG: imageTag,
148
+ MULTIVERSE_NEO4J_MODE: neo4jMode,
149
+ MULTIVERSE_NEO4J_DATABASE: stringFlag(flags["neo4j-database"]) || existingConfig.MULTIVERSE_NEO4J_DATABASE || "neo4j",
150
+ MULTIVERSE_NEO4J_USER: stringFlag(flags["neo4j-user"]) || existingConfig.MULTIVERSE_NEO4J_USER || "neo4j",
151
+ MULTIVERSE_NEO4J_URI: "",
152
+ MULTIVERSE_NEO4J_PASSWORD: ""
153
+ };
154
+
155
+ if (neo4jMode === "managed") {
156
+ config.MULTIVERSE_NEO4J_URI = "neo4j://neo4j:7687";
157
+ config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD || "123456";
158
+ } else {
159
+ config.MULTIVERSE_NEO4J_URI = stringFlag(flags["neo4j-uri"]) || existingConfig.MULTIVERSE_NEO4J_URI || "";
160
+ config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD || "";
161
+ }
162
+
163
+ validateConfig(config, { nonInteractive: toBool(flags["non-interactive"]) });
164
+ return config;
165
+ }
166
+
167
+ export function validateConfig(config, options = {}) {
168
+ if (!path.isAbsolute(config.MULTIVERSE_HOST_WORKSPACE_DIR)) {
169
+ throw new Error("Workspace path must be absolute.");
170
+ }
171
+ if (!config.MULTIVERSE_ADMIN_PASSWORD) {
172
+ throw new Error("Admin password is required.");
173
+ }
174
+ if (!["managed", "external"].includes(config.MULTIVERSE_NEO4J_MODE)) {
175
+ throw new Error("Neo4j mode must be managed or external.");
176
+ }
177
+ if (config.MULTIVERSE_NEO4J_MODE === "managed") {
178
+ if (!config.MULTIVERSE_NEO4J_PASSWORD) {
179
+ throw new Error("Neo4j password is required for managed mode.");
180
+ }
181
+ return;
182
+ }
183
+ if (!config.MULTIVERSE_NEO4J_URI) {
184
+ throw new Error("MULTIVERSE_NEO4J_URI is required for external mode.");
185
+ }
186
+ if (!config.MULTIVERSE_NEO4J_USER) {
187
+ throw new Error("MULTIVERSE_NEO4J_USER is required for external mode.");
188
+ }
189
+ if (!config.MULTIVERSE_NEO4J_PASSWORD && options.nonInteractive) {
190
+ throw new Error("MULTIVERSE_NEO4J_PASSWORD is required for external mode.");
191
+ }
192
+ }
193
+
194
+ export function renderEnvFile(config) {
195
+ const orderedKeys = [
196
+ "MULTIVERSE_HOST",
197
+ "MULTIVERSE_PORT",
198
+ "MULTIVERSE_API_PORT",
199
+ "MULTIVERSE_ADMIN_USER",
200
+ "MULTIVERSE_ADMIN_PASSWORD",
201
+ "MULTIVERSE_MCP_ALLOW_ANONYMOUS",
202
+ "MULTIVERSE_WORKSPACE_DIR",
203
+ "MULTIVERSE_HOST_WORKSPACE_DIR",
204
+ "DOCS_STORAGE_PATH",
205
+ "MULTIVERSE_ENABLE_AI",
206
+ "NINEROUTER_API_BASE_URL",
207
+ "NINEROUTER_MODEL_NAME",
208
+ "NINEROUTER_API_KEY",
209
+ "MULTIVERSE_ENABLE_EMBEDDINGS",
210
+ "EMBEDDING_API_BASE_URL",
211
+ "EMBEDDING_MODEL_NAME",
212
+ "EMBEDDING_API_KEY",
213
+ "MULTIVERSE_IMAGE_TAG",
214
+ "MULTIVERSE_NEO4J_MODE",
215
+ "MULTIVERSE_NEO4J_URI",
216
+ "MULTIVERSE_NEO4J_USER",
217
+ "MULTIVERSE_NEO4J_PASSWORD",
218
+ "MULTIVERSE_NEO4J_DATABASE"
219
+ ];
220
+ return [
221
+ "# Generated by git-multiverse setup",
222
+ "# Local-only secrets file. Do not commit.",
223
+ ...orderedKeys.map((key) => `${key}=${quoteEnv(config[key] ?? "")}`),
224
+ ""
225
+ ].join("\n");
226
+ }
227
+
228
+ export function renderComposeFile(config) {
229
+ const lines = [
230
+ "services:",
231
+ " multiverse:",
232
+ ` image: ${APP_IMAGE}:${config.MULTIVERSE_IMAGE_TAG}`,
233
+ " restart: unless-stopped",
234
+ " env_file:",
235
+ " - ./.env",
236
+ " ports:",
237
+ ' - "127.0.0.1:18081:18081"',
238
+ " volumes:",
239
+ ' - ${MULTIVERSE_HOST_WORKSPACE_DIR}:/workspace/repos',
240
+ ' - ./docs:/workspace/docs',
241
+ " ",
242
+ ];
243
+
244
+ if (config.MULTIVERSE_NEO4J_MODE === "managed") {
245
+ lines.push(" depends_on:", " - neo4j");
246
+ }
247
+
248
+ if (config.MULTIVERSE_NEO4J_MODE === "managed") {
249
+ lines.push(
250
+ " neo4j:",
251
+ ` image: ${NEO4J_IMAGE}`,
252
+ " restart: unless-stopped",
253
+ " environment:",
254
+ ' NEO4J_AUTH: neo4j/${MULTIVERSE_NEO4J_PASSWORD}',
255
+ ' NEO4J_dbms_security_auth__minimum__password__length: "6"',
256
+ " ports:",
257
+ ' - "127.0.0.1:7474:7474"',
258
+ ' - "127.0.0.1:7687:7687"',
259
+ " volumes:",
260
+ ' - ./neo4j-data:/data'
261
+ );
262
+ }
263
+
264
+ return lines.join("\n") + "\n";
265
+ }
266
+
267
+ export function writeStateFiles(config, overrides = {}) {
268
+ const fsModule = overrides.fsModule || fs;
269
+ const paths = getStatePaths(overrides);
270
+ fsModule.mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
271
+ fsModule.chmodSync(paths.stateDir, 0o700);
272
+ fsModule.writeFileSync(paths.envPath, renderEnvFile(config), { mode: 0o600 });
273
+ fsModule.chmodSync(paths.envPath, 0o600);
274
+ fsModule.writeFileSync(paths.composePath, renderComposeFile(config), "utf8");
275
+ return paths;
276
+ }
277
+
278
+ export function loadConfig(overrides = {}) {
279
+ const fsModule = overrides.fsModule || fs;
280
+ const { envPath } = getStatePaths(overrides);
281
+ if (!fsModule.existsSync(envPath)) {
282
+ throw new Error(`missing ${envPath}. Run: git-multiverse setup`);
283
+ }
284
+ return parseEnvFile(fsModule.readFileSync(envPath, "utf8"));
285
+ }
286
+
287
+ export function parseEnvFile(source) {
288
+ const result = {};
289
+ for (const line of source.split(/\r?\n/)) {
290
+ const trimmed = line.trim();
291
+ if (!trimmed || trimmed.startsWith("#")) continue;
292
+ const index = trimmed.indexOf("=");
293
+ if (index < 1) continue;
294
+ const key = trimmed.slice(0, index);
295
+ const raw = trimmed.slice(index + 1);
296
+ result[key] = unquoteEnv(raw);
297
+ }
298
+ return result;
299
+ }
300
+
301
+ export async function cmdSetup(flags, overrides = {}, io = createIO(overrides)) {
302
+ const nonInteractive = toBool(flags["non-interactive"]);
303
+ const fsModule = overrides.fsModule || fs;
304
+ const paths = getStatePaths(overrides);
305
+ let existingConfig = {};
306
+ if (fsModule.existsSync(paths.envPath)) {
307
+ try {
308
+ existingConfig = parseEnvFile(fsModule.readFileSync(paths.envPath, "utf8"));
309
+ } catch (e) {
310
+ // ignore
311
+ }
312
+ }
313
+
314
+ const config = createSetupConfig(flags, overrides, existingConfig);
315
+ const interactive = !nonInteractive && isInteractive(overrides);
316
+ if (interactive) {
317
+ await runWizard(config, flags, overrides, io);
318
+ validateConfig(config);
319
+ }
320
+ writeStateFiles(config, overrides);
321
+ io.log(`${colors.green}✔ Configuration successfully written:${colors.reset}`);
322
+ io.log(` - Env file: ${colors.cyan}${paths.envPath}${colors.reset} (Mode: 0600)`);
323
+ io.log(` - Docker Compose: ${colors.cyan}${paths.composePath}${colors.reset}`);
324
+ io.log(` - Neo4j Mode: ${colors.cyan}${config.MULTIVERSE_NEO4J_MODE}${colors.reset}`);
325
+ io.log(` - Admin password: ${colors.bold}${colors.green}${config.MULTIVERSE_ADMIN_PASSWORD}${colors.reset}`);
326
+
327
+ if (interactive) {
328
+ io.log(`\n${colors.green}${colors.bold}✔ Setup complete! Starting Multiverse containers automatically...${colors.reset}`);
329
+ await cmdUp(overrides, io);
330
+ }
331
+ }
332
+
333
+ export async function cmdUp(overrides = {}, io = createIO(overrides)) {
334
+ const paths = getStatePaths(overrides);
335
+ ensureDockerAvailable(overrides);
336
+ runDockerCompose(["up", "-d"], overrides, io, paths);
337
+ }
338
+
339
+ export async function cmdDown(overrides = {}, io = createIO(overrides)) {
340
+ const paths = getStatePaths(overrides);
341
+ ensureDockerAvailable(overrides);
342
+ runDockerCompose(["down"], overrides, io, paths);
343
+ }
344
+
345
+ export async function cmdStatus(overrides = {}, io = createIO(overrides)) {
346
+ const paths = getStatePaths(overrides);
347
+ ensureDockerAvailable(overrides);
348
+ runDockerCompose(["ps"], overrides, io, paths);
349
+ const health = await checkHealth("http://127.0.0.1:18081/api/health", overrides.fetchImpl || globalThis.fetch);
350
+ io.log(`health: ${health.ok ? health.message : `unreachable (${health.message})`}`);
351
+ if (!health.ok) {
352
+ if (typeof overrides.exit === "function") overrides.exit(1);
353
+ else process.exitCode = 1;
354
+ }
355
+ }
356
+
357
+ export async function cmdDoctor(overrides = {}, io = createIO(overrides)) {
358
+ const fsModule = overrides.fsModule || fs;
359
+ const paths = getStatePaths(overrides);
360
+ 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
+ const envExists = fsModule.existsSync(paths.envPath);
369
+ printDoctor(io, "env file", envExists, envExists ? paths.envPath : "run git-multiverse setup");
370
+ failed ||= !envExists;
371
+
372
+ let config = null;
373
+ if (envExists) {
374
+ config = parseEnvFile(fsModule.readFileSync(paths.envPath, "utf8"));
375
+ const envMode = fsModule.statSync(paths.envPath).mode & 0o777;
376
+ const isWindows = os.platform() === "win32";
377
+ const modeOk = isWindows || envMode === 0o600;
378
+ printDoctor(io, "env mode", modeOk, `0${envMode.toString(8)}`);
379
+ failed ||= !modeOk;
380
+ printDoctor(io, "neo4j mode", true, config.MULTIVERSE_NEO4J_MODE);
381
+ printDoctor(io, "mcp anonymous", config.MULTIVERSE_MCP_ALLOW_ANONYMOUS === "false", `MULTIVERSE_MCP_ALLOW_ANONYMOUS=${config.MULTIVERSE_MCP_ALLOW_ANONYMOUS}`);
382
+ failed ||= config.MULTIVERSE_MCP_ALLOW_ANONYMOUS !== "false";
383
+ const adminOk = Boolean(config.MULTIVERSE_ADMIN_PASSWORD && config.MULTIVERSE_ADMIN_PASSWORD !== "admin");
384
+ printDoctor(io, "admin password", adminOk, adminOk ? "custom password set" : "default password detected");
385
+ failed ||= !adminOk;
386
+ }
387
+
388
+ for (const port of [18081, 7474, 7687]) {
389
+ const status = await checkPortStatus(port, overrides.netModule || net);
390
+ printDoctor(io, `port ${port}`, true, status);
391
+ }
392
+
393
+ if (failed) {
394
+ if (typeof overrides.exit === "function") overrides.exit(1);
395
+ else process.exitCode = 1;
396
+ }
397
+ }
398
+
399
+ // ── MCP client setup ──────────────────────────────────────────────────────────
400
+
401
+ const MCP_EDITORS = ["claude", "cursor", "codex", "opencode", "windsurf"];
402
+
403
+ // buildMcpClientConfig returns the editor-specific MCP client configuration for
404
+ // connecting to a running git-multiverse server. The server exposes Streamable
405
+ // HTTP MCP at <baseUrl>/mcp and authenticates with a Bearer key (created in
406
+ // Settings → MCP Access Keys or POST /api/settings/mcp-keys). Pure function: no
407
+ // I/O, so it is straightforward to unit-test and stays identical across editors
408
+ // that share a schema.
409
+ export function buildMcpClientConfig(editor, baseUrl, mcpKey) {
410
+ const url = `${String(baseUrl).replace(/\/+$/, "")}/mcp`;
411
+ const headerValue = mcpKey ? `Bearer ${mcpKey}` : "Bearer <MCP_KEY>";
412
+ switch (editor) {
413
+ case "claude":
414
+ // claude mcp add expects an http transport with a header flag.
415
+ return {
416
+ kind: "shell",
417
+ command: `claude mcp add --transport http multiverse ${url} --header ${JSON.stringify(`Authorization: ${headerValue}`)}`
418
+ };
419
+ case "cursor":
420
+ case "windsurf":
421
+ // Cursor (~/.cursor/mcp.json) and Windsurf share the same JSON schema.
422
+ return {
423
+ kind: "json",
424
+ config: {
425
+ mcpServers: {
426
+ multiverse: {
427
+ url,
428
+ headers: { Authorization: headerValue }
429
+ }
430
+ }
431
+ }
432
+ };
433
+ case "opencode":
434
+ // opencode uses a "remote" MCP type with a headers map.
435
+ return {
436
+ kind: "json",
437
+ config: {
438
+ mcp: {
439
+ multiverse: {
440
+ type: "remote",
441
+ url,
442
+ headers: { Authorization: headerValue }
443
+ }
444
+ }
445
+ }
446
+ };
447
+ case "codex":
448
+ // Codex config.toml uses [mcp_servers.<name>] with a url + bearer token.
449
+ return {
450
+ kind: "toml",
451
+ config: `[mcp_servers.multiverse]\nurl = ${JSON.stringify(url)}\nbearer_token = ${JSON.stringify(mcpKey || "<MCP_KEY>")}\n`
452
+ };
453
+ default:
454
+ throw new Error(`Unknown editor: ${editor}. Supported: ${MCP_EDITORS.join(", ")}`);
455
+ }
456
+ }
457
+
458
+ // renderMcpClientConfig turns a buildMcpClientConfig result into a printable
459
+ // snippet a user can copy into their editor configuration.
460
+ export function renderMcpClientConfig(editor, baseUrl, mcpKey) {
461
+ const result = buildMcpClientConfig(editor, baseUrl, mcpKey);
462
+ switch (result.kind) {
463
+ case "shell":
464
+ return result.command;
465
+ case "toml":
466
+ return result.config;
467
+ case "json":
468
+ default:
469
+ return JSON.stringify(result.config, null, 2);
470
+ }
471
+ }
472
+
473
+ export async function cmdMcpSetup(flags, overrides = {}, io = createIO(overrides)) {
474
+ const baseUrl = stringFlag(flags["base-url"]) || `http://127.0.0.1:${DEFAULT_PORT}`;
475
+ const mcpKey = stringFlag(flags["mcp-key"]);
476
+ const requested = stringFlag(flags.editor);
477
+ const editors = requested ? [requested] : MCP_EDITORS;
478
+
479
+ if (!mcpKey) {
480
+ io.log("No --mcp-key provided. Create one in the web UI (Settings → MCP Access Keys)");
481
+ io.log("or via POST /api/settings/mcp-keys, then re-run with --mcp-key <key>.");
482
+ io.log("Showing config templates with a <MCP_KEY> placeholder:\n");
483
+ }
484
+
485
+ for (const editor of editors) {
486
+ io.log(`# ${editor}`);
487
+ io.log(renderMcpClientConfig(editor, baseUrl, mcpKey));
488
+ io.log("");
489
+ }
490
+ }
491
+
492
+ export function ensureDockerAvailable(overrides = {}) {
493
+ const docker = checkCommand(["docker", "--version"], overrides);
494
+ if (!docker.ok) throw new Error("Docker CLI not found.");
495
+ const compose = checkCommand(["docker", "compose", "version"], overrides);
496
+ if (!compose.ok) throw new Error("Docker Compose plugin not found.");
497
+ }
498
+
499
+ export function runDockerCompose(args, overrides = {}, io = createIO(overrides), paths = getStatePaths(overrides)) {
500
+ const config = loadConfig(overrides);
501
+ const commandArgs = [
502
+ "compose",
503
+ "--env-file",
504
+ paths.envPath,
505
+ "-f",
506
+ paths.composePath,
507
+ ...args
508
+ ];
509
+ const result = execCommand(["docker", ...commandArgs], overrides, { inherit: true });
510
+ if (!result.ok) {
511
+ throw new Error(`docker compose ${args.join(" ")} failed`);
512
+ }
513
+ io.log(`compose ${args.join(" ")} ok (${config.MULTIVERSE_NEO4J_MODE})`);
514
+ }
515
+
516
+ export function checkCommand(command, overrides = {}) {
517
+ const result = execCommand(command, overrides, { inherit: false });
518
+ return { ok: result.ok, output: result.output };
519
+ }
520
+
521
+ export function execCommand(command, overrides = {}, options = {}) {
522
+ const runner = overrides.spawnSyncImpl || spawnSync;
523
+ const [file, ...args] = command;
524
+ const result = runner(file, args, {
525
+ encoding: "utf8",
526
+ stdio: options.inherit ? "inherit" : "pipe"
527
+ });
528
+ const output = String(result?.stdout || result?.stderr || result?.error?.message || "").trim();
529
+ return { ok: !result?.error && result?.status === 0, output };
530
+ }
531
+
532
+ export async function checkHealth(url, fetchImpl = globalThis.fetch) {
533
+ if (typeof fetchImpl !== "function") {
534
+ return { ok: false, message: "fetch unavailable" };
535
+ }
536
+ try {
537
+ const response = await fetchImpl(url);
538
+ return response.ok ? { ok: true, message: `${response.status} ${response.statusText}` } : { ok: false, message: `${response.status} ${response.statusText}` };
539
+ } catch (error) {
540
+ return { ok: false, message: error.message };
541
+ }
542
+ }
543
+
544
+ export async function checkPortStatus(port, netModule = net) {
545
+ return new Promise((resolve) => {
546
+ const server = netModule.createServer();
547
+ server.once("error", () => resolve("in use"));
548
+ server.once("listening", () => {
549
+ server.close(() => resolve("available"));
550
+ });
551
+ server.listen(port, "127.0.0.1");
552
+ });
553
+ }
554
+
555
+ function showPageHeader(pageNum, title, io) {
556
+ console.clear();
557
+ io.log(`${colors.cyan}==================================================${colors.reset}`);
558
+ io.log(`${colors.bold}${colors.cyan} MULTIVERSE ONBOARDING WIZARD`);
559
+ io.log(` [Page ${pageNum} of 5: ${title}]${colors.reset}`);
560
+ io.log(`${colors.cyan}==================================================${colors.reset}`);
561
+ io.log(`${colors.yellow}ℹ Press Enter to keep default values.${colors.reset}`);
562
+ io.log("");
563
+ }
564
+
565
+ export async function runWizard(config, flags, overrides = {}, io = createIO(overrides)) {
566
+ const rl = readline.createInterface({ input: overrides.stdin || process.stdin, output: overrides.stdout || process.stdout });
567
+ try {
568
+ // ── Page 1: System & Workspace ──────────────────────────────────
569
+ showPageHeader(1, "System & Workspace", io);
570
+ if (!flags["neo4j-mode"]) {
571
+ config.MULTIVERSE_NEO4J_MODE = normalizeNeo4jMode(await prompt(rl, "Neo4j mode (managed/external)", config.MULTIVERSE_NEO4J_MODE));
572
+ }
573
+ if (!flags.workspace) {
574
+ config.MULTIVERSE_HOST_WORKSPACE_DIR = await prompt(rl, "Workspace host path", config.MULTIVERSE_HOST_WORKSPACE_DIR);
575
+ }
576
+
577
+ // ── Page 2: Security & Credentials ──────────────────────────────
578
+ showPageHeader(2, "Security & Credentials", io);
579
+ if (!flags["admin-password"]) {
580
+ config.MULTIVERSE_ADMIN_PASSWORD = await prompt(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD);
581
+ }
582
+ if (config.MULTIVERSE_NEO4J_MODE === "managed") {
583
+ if (!flags["neo4j-password"]) {
584
+ config.MULTIVERSE_NEO4J_PASSWORD = await prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
585
+ }
586
+ } else {
587
+ if (!flags["neo4j-uri"]) config.MULTIVERSE_NEO4J_URI = await prompt(rl, "Neo4j URI", config.MULTIVERSE_NEO4J_URI);
588
+ 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 prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
590
+ if (!flags["neo4j-database"]) config.MULTIVERSE_NEO4J_DATABASE = await prompt(rl, "Neo4j database", config.MULTIVERSE_NEO4J_DATABASE);
591
+ }
592
+
593
+ // ── Page 3: AI Provider (LLM) ───────────────────────────────────
594
+ showPageHeader(3, "AI Provider (LLM)", io);
595
+ if (!hasAny(flags, ["enable-ai", "llm-base-url", "llm-model", "llm-key"])) {
596
+ const defaultEnable = config.MULTIVERSE_ENABLE_AI === "true" ? "Y" : "N";
597
+ const answer = await prompt(rl, "Configure AI provider? (y/N)", defaultEnable);
598
+ if (/^y(es)?$/i.test(answer)) {
599
+ config.MULTIVERSE_ENABLE_AI = "true";
600
+ io.log("");
601
+ const providerOptions = [
602
+ { name: "OpenAI", value: "openai" },
603
+ { name: "DeepSeek", value: "deepseek" },
604
+ { name: "OpenRouter", value: "openrouter" },
605
+ { name: "Google / Gemini", value: "google" },
606
+ { name: "Anthropic / Claude", value: "claude" },
607
+ { name: "Amazon Bedrock", value: "bedrock" },
608
+ { name: "Custom (Enter URL & Model manually)", value: "custom" }
609
+ ];
610
+
611
+ let defaultProvider = "openai";
612
+ const currentUrl = config.NINEROUTER_API_BASE_URL;
613
+ if (currentUrl) {
614
+ if (currentUrl.includes("api.openai.com")) defaultProvider = "openai";
615
+ else if (currentUrl.includes("api.deepseek.com")) defaultProvider = "deepseek";
616
+ else if (currentUrl.includes("openrouter.ai")) defaultProvider = "openrouter";
617
+ else if (currentUrl.includes("generativelanguage.googleapis.com")) defaultProvider = "google";
618
+ else if (currentUrl.includes("api.anthropic.com")) defaultProvider = "claude";
619
+ else if (currentUrl.includes("amazonaws.com")) defaultProvider = "bedrock";
620
+ else defaultProvider = "custom";
621
+ }
622
+
623
+ const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider);
624
+ io.log("");
625
+
626
+ let baseUrl = "";
627
+ let presetModel = "";
628
+
629
+ if (provider === "openai") {
630
+ baseUrl = "https://api.openai.com/v1";
631
+ presetModel = "gpt-4o";
632
+ } else if (provider === "deepseek") {
633
+ baseUrl = "https://api.deepseek.com";
634
+ presetModel = "deepseek-chat";
635
+ } else if (provider === "openrouter") {
636
+ baseUrl = "https://openrouter.ai/api/v1";
637
+ presetModel = "google/gemini-2.5-flash";
638
+ } else if (provider === "google") {
639
+ baseUrl = "https://generativelanguage.googleapis.com/v1beta/openai";
640
+ presetModel = "gemini-1.5-flash";
641
+ } else if (provider === "claude") {
642
+ baseUrl = "https://api.anthropic.com/v1";
643
+ presetModel = "claude-3-5-sonnet-20241022";
644
+ } else if (provider === "bedrock") {
645
+ baseUrl = "https://bedrock-runtime.us-east-1.amazonaws.com";
646
+ presetModel = "anthropic.claude-3-5-sonnet-20241022-v2:0";
647
+ }
648
+
649
+ let defaultModel = config.NINEROUTER_MODEL_NAME;
650
+ if (provider !== "custom") {
651
+ const deducedPrevProvider = currentUrl ? defaultProvider : null;
652
+ if (provider !== deducedPrevProvider || !defaultModel) {
653
+ defaultModel = presetModel;
654
+ }
655
+ }
656
+
657
+ if (provider === "custom") {
658
+ const rawUrl = await prompt(rl, "LLM base URL", config.NINEROUTER_API_BASE_URL);
659
+ baseUrl = cleanBaseUrl(rawUrl, "llm");
660
+ if (baseUrl !== rawUrl.trim() && rawUrl.trim() !== "") {
661
+ io.log(`${colors.yellow}ℹ Auto-normalized URL to: ${baseUrl}${colors.reset}`);
662
+ }
663
+ if (!defaultModel) {
664
+ defaultModel = config.NINEROUTER_MODEL_NAME;
665
+ }
666
+ } else {
667
+ io.log(`${colors.green}ℹ Auto-configured URL: ${baseUrl}${colors.reset}`);
668
+ }
669
+
670
+ const modelName = await prompt(rl, "LLM model", defaultModel);
671
+
672
+ config.NINEROUTER_API_BASE_URL = baseUrl;
673
+ config.NINEROUTER_MODEL_NAME = modelName;
674
+ config.NINEROUTER_API_KEY = await prompt(rl, "LLM API key", config.NINEROUTER_API_KEY);
675
+ } else {
676
+ config.MULTIVERSE_ENABLE_AI = "false";
677
+ }
678
+ }
679
+
680
+ // ── Page 4: Vector Embeddings ───────────────────────────────────
681
+ showPageHeader(4, "Vector Embeddings", io);
682
+ if (!hasAny(flags, ["enable-embeddings", "embedding-base-url", "embedding-model", "embedding-key"])) {
683
+ const defaultEnable = config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "Y" : "N";
684
+ const answer = await prompt(rl, "Configure embedding provider? (y/N)", defaultEnable);
685
+ if (/^y(es)?$/i.test(answer)) {
686
+ config.MULTIVERSE_ENABLE_EMBEDDINGS = "true";
687
+ io.log("");
688
+ const embedOptions = [
689
+ { name: "OpenAI", value: "openai" },
690
+ { name: "OpenRouter", value: "openrouter" },
691
+ { name: "Custom (Enter URL & Model manually)", value: "custom" }
692
+ ];
693
+
694
+ let defaultEmbed = "openai";
695
+ const currentEmbedUrl = config.EMBEDDING_API_BASE_URL;
696
+ if (currentEmbedUrl) {
697
+ if (currentEmbedUrl.includes("api.openai.com")) defaultEmbed = "openai";
698
+ else if (currentEmbedUrl.includes("openrouter.ai")) defaultEmbed = "openrouter";
699
+ else defaultEmbed = "custom";
700
+ }
701
+
702
+ const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed);
703
+ io.log("");
704
+
705
+ let baseUrl = "";
706
+ let presetModel = "";
707
+
708
+ if (provider === "openai") {
709
+ baseUrl = "https://api.openai.com/v1";
710
+ presetModel = "text-embedding-3-small";
711
+ } else if (provider === "openrouter") {
712
+ baseUrl = "https://openrouter.ai/api/v1";
713
+ presetModel = "openai/text-embedding-3-small";
714
+ }
715
+
716
+ let defaultModel = config.EMBEDDING_MODEL_NAME;
717
+ if (provider !== "custom") {
718
+ const deducedPrevProvider = currentEmbedUrl ? defaultEmbed : null;
719
+ if (provider !== deducedPrevProvider || !defaultModel) {
720
+ defaultModel = presetModel;
721
+ }
722
+ }
723
+
724
+ if (provider === "custom") {
725
+ const rawUrl = await prompt(rl, "Embedding base URL", config.EMBEDDING_API_BASE_URL);
726
+ baseUrl = cleanBaseUrl(rawUrl, "embedding");
727
+ if (baseUrl !== rawUrl.trim() && rawUrl.trim() !== "") {
728
+ io.log(`${colors.yellow}ℹ Auto-normalized URL to: ${baseUrl}${colors.reset}`);
729
+ }
730
+ if (!defaultModel) {
731
+ defaultModel = config.EMBEDDING_MODEL_NAME;
732
+ }
733
+ } else {
734
+ io.log(`${colors.green}ℹ Auto-configured URL: ${baseUrl}${colors.reset}`);
735
+ }
736
+
737
+ const modelName = await prompt(rl, "Embedding model", defaultModel);
738
+
739
+ config.EMBEDDING_API_BASE_URL = baseUrl;
740
+ config.EMBEDDING_MODEL_NAME = modelName;
741
+ config.EMBEDDING_API_KEY = await prompt(rl, "Embedding API key", config.EMBEDDING_API_KEY);
742
+ } else {
743
+ config.MULTIVERSE_ENABLE_EMBEDDINGS = "false";
744
+ }
745
+ }
746
+
747
+ // ── Page 5: Docker Image & Finish ────────────────────────────────
748
+ showPageHeader(5, "Docker Image & Finish", io);
749
+ if (!flags["image-tag"]) {
750
+ config.MULTIVERSE_IMAGE_TAG = await prompt(rl, "Multiverse image tag", config.MULTIVERSE_IMAGE_TAG);
751
+ }
752
+ console.clear();
753
+ io.log(`${colors.green}==================================================`);
754
+ io.log(" MULTIVERSE ONBOARDING WIZARD");
755
+ io.log(" [SETUP COMPLETE]");
756
+ io.log(`==================================================${colors.reset}`);
757
+ io.log("");
758
+ } finally {
759
+ rl.close();
760
+ }
761
+ }
762
+
763
+ export function printHelp(log = console.log) {
764
+ log(`git-multiverse
765
+
766
+ Commands:
767
+ git-multiverse setup [--non-interactive] [--neo4j-mode managed|external]
768
+ [--workspace /absolute/path]
769
+ [--admin-password <password>]
770
+ [--neo4j-uri <uri>] [--neo4j-user <user>] [--neo4j-password <password>] [--neo4j-database <db>]
771
+ [--enable-ai] [--llm-base-url <url>] [--llm-model <name>] [--llm-key <key>]
772
+ [--enable-embeddings] [--embedding-base-url <url>] [--embedding-model <name>] [--embedding-key <key>]
773
+ [--image-tag <tag>]
774
+ git-multiverse up
775
+ git-multiverse down
776
+ git-multiverse status
777
+ git-multiverse doctor
778
+ git-multiverse mcp-setup [--editor claude|cursor|codex|opencode|windsurf]
779
+ [--base-url <url>] [--mcp-key <key>]`);
780
+ }
781
+
782
+ function printDoctor(io, label, ok, detail) {
783
+ io.log(`${ok ? "OK" : "FAIL"} ${label}: ${detail}`);
784
+ }
785
+
786
+ function createIO(overrides = {}) {
787
+ return {
788
+ log: overrides.log || console.log,
789
+ error: overrides.error || console.error
790
+ };
791
+ }
792
+
793
+ function hasAny(flags, keys) {
794
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(flags, key));
795
+ }
796
+
797
+ function normalizeNeo4jMode(value) {
798
+ if (value !== "managed" && value !== "external") {
799
+ throw new Error("Use --neo4j-mode managed|external.");
800
+ }
801
+ return value;
802
+ }
803
+
804
+ function quoteEnv(value) {
805
+ const stringValue = String(value);
806
+ return /^[A-Za-z0-9_./:@-]*$/.test(stringValue) ? stringValue : JSON.stringify(stringValue);
807
+ }
808
+
809
+ function unquoteEnv(value) {
810
+ if (value.startsWith('"') && value.endsWith('"')) {
811
+ return JSON.parse(value);
812
+ }
813
+ return value;
814
+ }
815
+
816
+ function randomPassword(length, randomBytesImpl = randomBytes) {
817
+ const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
818
+ let output = "";
819
+ while (output.length < length) {
820
+ for (const byte of randomBytesImpl(length)) {
821
+ output += alphabet[byte % alphabet.length];
822
+ if (output.length === length) return output;
823
+ }
824
+ }
825
+ return output;
826
+ }
827
+
828
+ function stringFlag(value) {
829
+ if (value === undefined || value === null || value === true) return "";
830
+ return String(value).trim();
831
+ }
832
+
833
+ function toBool(value) {
834
+ if (value === true) return true;
835
+ if (value === false || value === undefined || value === null || value === "") return false;
836
+ return ["1", "true", "yes", "y", "on"].includes(String(value).toLowerCase());
837
+ }
838
+
839
+ function isInteractive(overrides = {}) {
840
+ const stdin = overrides.stdin || process.stdin;
841
+ const stdout = overrides.stdout || process.stdout;
842
+ return Boolean(stdin.isTTY && stdout.isTTY);
843
+ }
844
+
845
+ async function prompt(rl, label, defaultValue) {
846
+ const suffix = defaultValue ? ` [${colors.cyan}${defaultValue}${colors.reset}]` : "";
847
+ const answer = await rl.question(`${colors.bold}${colors.white}? ${label}${colors.reset}${suffix}: `);
848
+ return answer.trim() || defaultValue || "";
849
+ }
850
+
851
+ async function promptMenu(rl, label, options, defaultValue) {
852
+ const stdin = process.stdin;
853
+ const stdout = process.stdout;
854
+
855
+ rl.pause(); // Pause readline interface to prevent double echoing
856
+
857
+ readlineModule.emitKeypressEvents(stdin);
858
+ if (stdin.isTTY) {
859
+ stdin.setRawMode(true);
860
+ }
861
+
862
+ let activeIdx = options.findIndex(o => o.name === defaultValue || o.value === defaultValue);
863
+ if (activeIdx === -1) activeIdx = 0;
864
+
865
+ const renderMenu = () => {
866
+ stdout.write("\x1B[?25l"); // Hide cursor
867
+ stdout.write(`${colors.bold}${colors.magenta}? ${label}${colors.reset}\n`);
868
+ for (let i = 0; i < options.length; i++) {
869
+ if (i === activeIdx) {
870
+ stdout.write(` ${colors.cyan}❯ ${options[i].name}${colors.reset}\n`);
871
+ } else {
872
+ stdout.write(` ${options[i].name}\n`);
873
+ }
874
+ }
875
+ };
876
+
877
+ const clearMenu = () => {
878
+ const linesToClear = options.length + 1;
879
+ for (let i = 0; i < linesToClear; i++) {
880
+ stdout.write("\x1B[1A\x1B[2K");
881
+ }
882
+ };
883
+
884
+ renderMenu();
885
+
886
+ return new Promise((resolve) => {
887
+ const onKeypress = (str, key) => {
888
+ if (key && key.ctrl && key.name === "c") {
889
+ stdout.write("\x1B[?25h"); // Show cursor
890
+ process.exit(0);
891
+ }
892
+ if (key && (key.name === "up" || key.name === "k")) {
893
+ activeIdx = (activeIdx - 1 + options.length) % options.length;
894
+ clearMenu();
895
+ renderMenu();
896
+ } else if (key && (key.name === "down" || key.name === "j")) {
897
+ activeIdx = (activeIdx + 1) % options.length;
898
+ clearMenu();
899
+ renderMenu();
900
+ } else if (key && (key.name === "return" || key.name === "enter")) {
901
+ stdout.write("\x1B[?25h"); // Show cursor
902
+ stdin.removeListener("keypress", onKeypress);
903
+ if (stdin.isTTY) {
904
+ stdin.setRawMode(false);
905
+ }
906
+ stdout.write(`\n`);
907
+ rl.resume(); // Resume readline interface
908
+ resolve(options[activeIdx].value);
909
+ }
910
+ };
911
+ stdin.on("keypress", onKeypress);
912
+ });
913
+ }
914
+
915
+ export function redactEnvText(text) {
916
+ const config = parseEnvFile(text);
917
+ for (const key of Object.keys(config)) {
918
+ if (SECRET_KEYS.has(key) && config[key]) {
919
+ config[key] = "<redacted>";
920
+ }
921
+ }
922
+ return renderEnvFile(config);
923
+ }
924
+
925
+ export function cleanBaseUrl(url, type) {
926
+ if (!url) return "";
927
+ let cleaned = String(url).trim();
928
+
929
+ if (type === "llm") {
930
+ if (cleaned.endsWith("/chat/completions")) {
931
+ cleaned = cleaned.slice(0, -"/chat/completions".length);
932
+ } else if (cleaned.endsWith("/chat/completions/")) {
933
+ cleaned = cleaned.slice(0, -"/chat/completions/".length);
934
+ }
935
+ } else if (type === "embedding") {
936
+ if (cleaned.endsWith("/embeddings")) {
937
+ cleaned = cleaned.slice(0, -"/embeddings".length);
938
+ } else if (cleaned.endsWith("/embeddings/")) {
939
+ cleaned = cleaned.slice(0, -"/embeddings/".length);
940
+ }
941
+ }
942
+
943
+ while (cleaned.endsWith("/")) {
944
+ cleaned = cleaned.slice(0, -1);
945
+ }
946
+
947
+ return cleaned;
948
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "git-multiverse",
3
+ "version": "0.1.1",
4
+ "description": "Clone-free Multiverse Docker Hub launcher.",
5
+ "type": "module",
6
+ "bin": {
7
+ "git-multiverse": "./bin/git-multiverse.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "doctor": "node ./bin/git-multiverse.mjs doctor"
12
+ },
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "license": "Apache-2.0"
17
+ }
@@ -0,0 +1,179 @@
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
+ });