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 CHANGED
@@ -12,10 +12,11 @@ npm install -g git-multiverse
12
12
 
13
13
  ```bash
14
14
  git-multiverse setup
15
- git-multiverse up
16
- git-multiverse down
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 a standalone compose file that uses:
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 managed Neo4j
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
- --neo4j-mode managed \
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
+ ```
@@ -2,4 +2,7 @@
2
2
 
3
3
  import { main } from "../lib/app.mjs";
4
4
 
5
- await main(process.argv.slice(2));
5
+ main(process.argv.slice(2)).catch((error) => {
6
+ console.error(`error: ${error.message}`);
7
+ process.exitCode = 1;
8
+ });
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 "up":
44
- await cmdUp(overrides, io);
45
+ case "start":
46
+ await cmdStart(overrides, io);
45
47
  break;
46
- case "down":
47
- await cmdDown(overrides, io);
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 neo4jMode = normalizeNeo4jMode(
117
- stringFlag(flags["neo4j-mode"]) ||
118
- existingConfig.MULTIVERSE_NEO4J_MODE ||
119
- env.GIT_MULTIVERSE_NEO4J_MODE ||
120
- "managed"
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 || "123456",
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
- MULTIVERSE_NEO4J_MODE: neo4jMode,
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 (neo4jMode === "managed") {
196
+ if (dbMode === "local") {
156
197
  config.MULTIVERSE_NEO4J_URI = "neo4j://neo4j:7687";
157
- config.MULTIVERSE_NEO4J_PASSWORD = neo4jPassword || existingConfig.MULTIVERSE_NEO4J_PASSWORD || "123456";
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
- validateConfig(config, { nonInteractive: toBool(flags["non-interactive"]) });
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 (!["managed", "external"].includes(config.MULTIVERSE_NEO4J_MODE)) {
175
- throw new Error("Neo4j mode must be managed or external.");
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.MULTIVERSE_NEO4J_MODE === "managed") {
226
+ if (config.MULTIVERSE_DB_MODE === "local") {
178
227
  if (!config.MULTIVERSE_NEO4J_PASSWORD) {
179
- throw new Error("Neo4j password is required for managed mode.");
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 external mode.");
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 external mode.");
236
+ throw new Error("MULTIVERSE_NEO4J_USER is required for remote database mode.");
188
237
  }
189
- if (!config.MULTIVERSE_NEO4J_PASSWORD && options.nonInteractive) {
190
- throw new Error("MULTIVERSE_NEO4J_PASSWORD is required for external mode.");
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.MULTIVERSE_NEO4J_MODE === "managed") {
298
+ if (config.MULTIVERSE_DB_MODE === "local") {
245
299
  lines.push(" depends_on:", " - neo4j");
246
300
  }
247
301
 
248
- if (config.MULTIVERSE_NEO4J_MODE === "managed") {
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
- return parseEnvFile(fsModule.readFileSync(envPath, "utf8"));
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
- 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
- }
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(` - Neo4j Mode: ${colors.cyan}${config.MULTIVERSE_NEO4J_MODE}${colors.reset}`);
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
- if (interactive) {
328
- io.log(`\n${colors.green}${colors.bold}✔ Setup complete! Starting Multiverse containers automatically...${colors.reset}`);
329
- await cmdUp(overrides, io);
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 cmdUp(overrides = {}, io = createIO(overrides)) {
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 cmdDown(overrides = {}, io = createIO(overrides)) {
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
- ensureDockerAvailable(overrides);
348
- runDockerCompose(["ps"], overrides, io, paths);
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, "neo4j mode", true, config.MULTIVERSE_NEO4J_MODE);
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} MULTIVERSE ONBOARDING WIZARD`);
559
- io.log(` [Page ${pageNum} of 5: ${title}]${colors.reset}`);
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
- io.log(`${colors.yellow}ℹ Press Enter to keep default values.${colors.reset}`);
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["neo4j-mode"]) {
571
- config.MULTIVERSE_NEO4J_MODE = normalizeNeo4jMode(await prompt(rl, "Neo4j mode (managed/external)", config.MULTIVERSE_NEO4J_MODE));
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 prompt(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD);
872
+ config.MULTIVERSE_ADMIN_PASSWORD = await promptSecret(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD, { required: true });
581
873
  }
582
- if (config.MULTIVERSE_NEO4J_MODE === "managed") {
874
+ if (config.MULTIVERSE_DB_MODE === "local") {
583
875
  if (!flags["neo4j-password"]) {
584
- config.MULTIVERSE_NEO4J_PASSWORD = await prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
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 prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
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 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)) {
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 prompt(rl, "LLM API key", config.NINEROUTER_API_KEY);
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 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)) {
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 prompt(rl, "Embedding API key", config.EMBEDDING_API_KEY);
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
- 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("");
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] [--neo4j-mode managed|external]
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 up
775
- git-multiverse down
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 promptMenu(rl, label, options, defaultValue) {
852
- const stdin = process.stdin;
853
- const stdout = process.stdout;
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
- if (stdin.isTTY) {
859
- stdin.setRawMode(true);
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} ${options[i].name}${colors.reset}\n`);
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
- if (stdin.isTTY) {
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.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
- });