git-multiverse 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,74 @@ export async function checkPortStatus(port, netModule = net) {
552
824
  });
553
825
  }
554
826
 
555
- function showPageHeader(pageNum, title, io) {
827
+ function showPageHeader(pageNum, title, io, context = {}) {
556
828
  console.clear();
557
829
  io.log(`${colors.cyan}==================================================${colors.reset}`);
558
- io.log(`${colors.bold}${colors.cyan} 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
+ const runtimeOptions = [{ name: "Docker Compose (recommended)", value: "docker" }];
852
+ const nativeBinary = resolveNativeBinary(overrides);
853
+ if (nativeBinary.ok) {
854
+ runtimeOptions.push({ name: "Native binary + remote Neo4j", value: "native" });
855
+ } else {
856
+ io.log(`${colors.yellow}Native runtime is not available in this npm package for ${nativePlatformDir(overrides.platform || os.platform(), overrides.arch || os.arch())}.${colors.reset}`);
857
+ }
858
+ config.MULTIVERSE_RUNTIME_MODE = await promptMenu(rl, "Select runtime mode:", runtimeOptions, "docker", overrides);
859
+ }
860
+ if (config.MULTIVERSE_RUNTIME_MODE === "native") {
861
+ config.MULTIVERSE_DB_MODE = "remote";
862
+ config.MULTIVERSE_NEO4J_MODE = "external";
863
+ } else if (!flags["db-mode"] && !flags["neo4j-mode"]) {
864
+ config.MULTIVERSE_DB_MODE = await promptMenu(rl, "Select database mode:", [
865
+ { name: "Local Neo4j container", value: "local" },
866
+ { name: "Remote database", value: "remote" }
867
+ ], config.MULTIVERSE_DB_MODE, overrides);
868
+ config.MULTIVERSE_NEO4J_MODE = config.MULTIVERSE_DB_MODE === "local" ? "managed" : "external";
572
869
  }
573
870
  if (!flags.workspace) {
574
871
  config.MULTIVERSE_HOST_WORKSPACE_DIR = await prompt(rl, "Workspace host path", config.MULTIVERSE_HOST_WORKSPACE_DIR);
575
872
  }
576
873
 
577
874
  // ── Page 2: Security & Credentials ──────────────────────────────
578
- showPageHeader(2, "Security & Credentials", io);
875
+ showPageHeader(2, "Security & Credentials", io, wizardContext);
579
876
  if (!flags["admin-password"]) {
580
- config.MULTIVERSE_ADMIN_PASSWORD = await prompt(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD);
877
+ config.MULTIVERSE_ADMIN_PASSWORD = await promptSecret(rl, "Admin password", config.MULTIVERSE_ADMIN_PASSWORD, { required: true });
581
878
  }
582
- if (config.MULTIVERSE_NEO4J_MODE === "managed") {
879
+ if (config.MULTIVERSE_DB_MODE === "local") {
583
880
  if (!flags["neo4j-password"]) {
584
- config.MULTIVERSE_NEO4J_PASSWORD = await prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
881
+ config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Local Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
585
882
  }
586
883
  } else {
587
884
  if (!flags["neo4j-uri"]) config.MULTIVERSE_NEO4J_URI = await prompt(rl, "Neo4j URI", config.MULTIVERSE_NEO4J_URI);
588
885
  if (!flags["neo4j-user"]) config.MULTIVERSE_NEO4J_USER = await prompt(rl, "Neo4j user", config.MULTIVERSE_NEO4J_USER);
589
- if (!flags["neo4j-password"]) config.MULTIVERSE_NEO4J_PASSWORD = await prompt(rl, "Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD);
886
+ if (!flags["neo4j-password"]) config.MULTIVERSE_NEO4J_PASSWORD = await promptSecret(rl, "Remote Neo4j password", config.MULTIVERSE_NEO4J_PASSWORD, { required: true });
590
887
  if (!flags["neo4j-database"]) config.MULTIVERSE_NEO4J_DATABASE = await prompt(rl, "Neo4j database", config.MULTIVERSE_NEO4J_DATABASE);
591
888
  }
592
889
 
593
890
  // ── Page 3: AI Provider (LLM) ───────────────────────────────────
594
- showPageHeader(3, "AI Provider (LLM)", io);
891
+ showPageHeader(3, "AI Provider (LLM)", io, wizardContext);
595
892
  if (!hasAny(flags, ["enable-ai", "llm-base-url", "llm-model", "llm-key"])) {
596
- const 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)) {
893
+ const answer = await promptMenu(rl, "Configure AI provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_AI === "true" ? "yes" : "no", overrides);
894
+ if (answer === "yes") {
599
895
  config.MULTIVERSE_ENABLE_AI = "true";
600
896
  io.log("");
601
897
  const providerOptions = [
@@ -620,7 +916,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
620
916
  else defaultProvider = "custom";
621
917
  }
622
918
 
623
- const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider);
919
+ const provider = await promptMenu(rl, "Select AI Provider (LLM):", providerOptions, defaultProvider, overrides);
624
920
  io.log("");
625
921
 
626
922
  let baseUrl = "";
@@ -671,18 +967,17 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
671
967
 
672
968
  config.NINEROUTER_API_BASE_URL = baseUrl;
673
969
  config.NINEROUTER_MODEL_NAME = modelName;
674
- config.NINEROUTER_API_KEY = await prompt(rl, "LLM API key", config.NINEROUTER_API_KEY);
970
+ config.NINEROUTER_API_KEY = await promptSecret(rl, "LLM API key", config.NINEROUTER_API_KEY);
675
971
  } else {
676
972
  config.MULTIVERSE_ENABLE_AI = "false";
677
973
  }
678
974
  }
679
975
 
680
976
  // ── Page 4: Vector Embeddings ───────────────────────────────────
681
- showPageHeader(4, "Vector Embeddings", io);
977
+ showPageHeader(4, "Vector Embeddings", io, wizardContext);
682
978
  if (!hasAny(flags, ["enable-embeddings", "embedding-base-url", "embedding-model", "embedding-key"])) {
683
- const 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)) {
979
+ const answer = await promptMenu(rl, "Configure embedding provider?", yesNoOptions(), config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "yes" : "no", overrides);
980
+ if (answer === "yes") {
686
981
  config.MULTIVERSE_ENABLE_EMBEDDINGS = "true";
687
982
  io.log("");
688
983
  const embedOptions = [
@@ -699,7 +994,7 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
699
994
  else defaultEmbed = "custom";
700
995
  }
701
996
 
702
- const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed);
997
+ const provider = await promptMenu(rl, "Select Vector Embedding Provider:", embedOptions, defaultEmbed, overrides);
703
998
  io.log("");
704
999
 
705
1000
  let baseUrl = "";
@@ -738,23 +1033,22 @@ export async function runWizard(config, flags, overrides = {}, io = createIO(ove
738
1033
 
739
1034
  config.EMBEDDING_API_BASE_URL = baseUrl;
740
1035
  config.EMBEDDING_MODEL_NAME = modelName;
741
- config.EMBEDDING_API_KEY = await prompt(rl, "Embedding API key", config.EMBEDDING_API_KEY);
1036
+ config.EMBEDDING_API_KEY = await promptSecret(rl, "Embedding API key", config.EMBEDDING_API_KEY);
742
1037
  } else {
743
1038
  config.MULTIVERSE_ENABLE_EMBEDDINGS = "false";
744
1039
  }
745
1040
  }
746
1041
 
747
1042
  // ── Page 5: Docker Image & Finish ────────────────────────────────
748
- showPageHeader(5, "Docker Image & Finish", io);
1043
+ showPageHeader(5, "Docker Image & Finish", io, wizardContext);
749
1044
  if (!flags["image-tag"]) {
750
1045
  config.MULTIVERSE_IMAGE_TAG = await prompt(rl, "Multiverse image tag", config.MULTIVERSE_IMAGE_TAG);
751
1046
  }
752
- 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("");
1047
+ showSetupSummary(config, paths.envPath, io);
1048
+ const confirm = await promptMenu(rl, "Save this configuration and start Multiverse?", yesNoOptions(), "yes", overrides);
1049
+ if (confirm !== "yes") {
1050
+ throw new Error("setup cancelled; existing configuration was not changed");
1051
+ }
758
1052
  } finally {
759
1053
  rl.close();
760
1054
  }
@@ -764,17 +1058,20 @@ export function printHelp(log = console.log) {
764
1058
  log(`git-multiverse
765
1059
 
766
1060
  Commands:
767
- git-multiverse setup [--non-interactive] [--neo4j-mode managed|external]
1061
+ git-multiverse setup [--non-interactive] [--runtime docker|native] [--db-mode local|remote]
768
1062
  [--workspace /absolute/path]
769
1063
  [--admin-password <password>]
770
1064
  [--neo4j-uri <uri>] [--neo4j-user <user>] [--neo4j-password <password>] [--neo4j-database <db>]
771
1065
  [--enable-ai] [--llm-base-url <url>] [--llm-model <name>] [--llm-key <key>]
772
1066
  [--enable-embeddings] [--embedding-base-url <url>] [--embedding-model <name>] [--embedding-key <key>]
773
1067
  [--image-tag <tag>]
774
- git-multiverse up
775
- git-multiverse down
1068
+ git-multiverse start
1069
+ git-multiverse stop
776
1070
  git-multiverse status
1071
+ git-multiverse logs [--tail 200]
1072
+ git-multiverse verify
777
1073
  git-multiverse doctor
1074
+ git-multiverse uninstall
778
1075
  git-multiverse mcp-setup [--editor claude|cursor|codex|opencode|windsurf]
779
1076
  [--base-url <url>] [--mcp-key <key>]`);
780
1077
  }
@@ -794,6 +1091,17 @@ function hasAny(flags, keys) {
794
1091
  return keys.some((key) => Object.prototype.hasOwnProperty.call(flags, key));
795
1092
  }
796
1093
 
1094
+ function ensureRuntimeSupported(config, overrides = {}) {
1095
+ if (config.MULTIVERSE_RUNTIME_MODE === "native") {
1096
+ const resolved = resolveNativeBinary(overrides);
1097
+ if (!resolved.ok) throw new Error(nativeRuntimeUnavailableMessage(resolved.message));
1098
+ }
1099
+ }
1100
+
1101
+ function nativeRuntimeUnavailableMessage(detail = "") {
1102
+ return `Native + Remote Database runtime is not available in this package yet. Use Docker Compose mode for now, or install a packaged multiverse-api binary. ${detail}`.trim();
1103
+ }
1104
+
797
1105
  function normalizeNeo4jMode(value) {
798
1106
  if (value !== "managed" && value !== "external") {
799
1107
  throw new Error("Use --neo4j-mode managed|external.");
@@ -801,6 +1109,33 @@ function normalizeNeo4jMode(value) {
801
1109
  return value;
802
1110
  }
803
1111
 
1112
+ function normalizeRuntimeMode(value) {
1113
+ if (value === "non-docker") return "native";
1114
+ if (value !== "docker" && value !== "native") {
1115
+ throw new Error("Use --runtime docker|native.");
1116
+ }
1117
+ return value;
1118
+ }
1119
+
1120
+ function normalizeDbMode(value) {
1121
+ if (value !== "local" && value !== "remote") {
1122
+ throw new Error("Use --db-mode local|remote.");
1123
+ }
1124
+ return value;
1125
+ }
1126
+
1127
+ function legacyNeo4jModeToDbMode(value) {
1128
+ if (!value) return "";
1129
+ return normalizeNeo4jMode(value) === "managed" ? "local" : "remote";
1130
+ }
1131
+
1132
+ function yesNoOptions() {
1133
+ return [
1134
+ { name: "No", value: "no" },
1135
+ { name: "Yes", value: "yes" }
1136
+ ];
1137
+ }
1138
+
804
1139
  function quoteEnv(value) {
805
1140
  const stringValue = String(value);
806
1141
  return /^[A-Za-z0-9_./:@-]*$/.test(stringValue) ? stringValue : JSON.stringify(stringValue);
@@ -848,16 +1183,59 @@ async function prompt(rl, label, defaultValue) {
848
1183
  return answer.trim() || defaultValue || "";
849
1184
  }
850
1185
 
851
- async function promptMenu(rl, label, options, defaultValue) {
852
- const stdin = process.stdin;
853
- const stdout = process.stdout;
1186
+ async function promptSecret(rl, label, currentValue = "", options = {}) {
1187
+ for (;;) {
1188
+ const suffix = currentValue ? ` [${colors.cyan}configured, press Enter to keep${colors.reset}]` : "";
1189
+ const answer = await rl.question(`${colors.bold}${colors.white}? ${label}${colors.reset}${suffix}: `);
1190
+ const trimmed = answer.trim();
1191
+ if (trimmed) return trimmed;
1192
+ if (currentValue) return currentValue;
1193
+ if (!options.required) return "";
1194
+ console.log(`${colors.yellow}${label} is required.${colors.reset}`);
1195
+ }
1196
+ }
1197
+
1198
+ async function promptRequired(rl, label) {
1199
+ for (;;) {
1200
+ const answer = await prompt(rl, label, "");
1201
+ if (answer.trim()) return answer.trim();
1202
+ console.log(`${colors.yellow}${label} is required.${colors.reset}`);
1203
+ }
1204
+ }
1205
+
1206
+ function showSetupSummary(config, envPath, io) {
1207
+ console.clear();
1208
+ io.log(`${colors.cyan}==================================================${colors.reset}`);
1209
+ io.log(`${colors.bold}${colors.cyan} Review Multiverse Setup${colors.reset}`);
1210
+ io.log(`${colors.cyan}==================================================${colors.reset}`);
1211
+ io.log(`Config: ${colors.cyan}${envPath}${colors.reset}`);
1212
+ io.log(`Runtime: ${colors.cyan}${config.MULTIVERSE_RUNTIME_MODE}${colors.reset}`);
1213
+ io.log(`Database mode: ${colors.cyan}${config.MULTIVERSE_DB_MODE}${colors.reset}`);
1214
+ io.log(`Neo4j URI: ${colors.cyan}${config.MULTIVERSE_NEO4J_URI || "<not set>"}${colors.reset}`);
1215
+ io.log(`Neo4j user: ${colors.cyan}${config.MULTIVERSE_NEO4J_USER || "<not set>"}${colors.reset}`);
1216
+ io.log(`Neo4j password: ${colors.cyan}${config.MULTIVERSE_NEO4J_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
1217
+ io.log(`Workspace path: ${colors.cyan}${config.MULTIVERSE_HOST_WORKSPACE_DIR}${colors.reset}`);
1218
+ io.log(`Admin user: ${colors.cyan}${config.MULTIVERSE_ADMIN_USER}${colors.reset}`);
1219
+ io.log(`Admin password: ${colors.cyan}${config.MULTIVERSE_ADMIN_PASSWORD ? "<configured>" : "<missing>"}${colors.reset}`);
1220
+ io.log(`AI provider: ${colors.cyan}${config.MULTIVERSE_ENABLE_AI === "true" ? "enabled" : "disabled"}${colors.reset}`);
1221
+ io.log(`Embeddings: ${colors.cyan}${config.MULTIVERSE_ENABLE_EMBEDDINGS === "true" ? "enabled" : "disabled"}${colors.reset}`);
1222
+ io.log("");
1223
+ }
1224
+
1225
+ async function promptMenu(rl, label, options, defaultValue, overrides = {}) {
1226
+ const stdin = overrides.stdin || process.stdin;
1227
+ const stdout = overrides.stdout || process.stdout;
1228
+ if (!stdin.isTTY || !stdout.isTTY) {
1229
+ const defaultOption = options.find((option) => option.name === defaultValue || option.value === defaultValue) || options[0];
1230
+ const answer = await prompt(rl, label, defaultOption.value);
1231
+ return options.find((option) => option.value === answer || option.name === answer)?.value || defaultOption.value;
1232
+ }
854
1233
 
855
1234
  rl.pause(); // Pause readline interface to prevent double echoing
856
1235
 
857
1236
  readlineModule.emitKeypressEvents(stdin);
858
- if (stdin.isTTY) {
859
- stdin.setRawMode(true);
860
- }
1237
+ stdin.resume();
1238
+ stdin.setRawMode(true);
861
1239
 
862
1240
  let activeIdx = options.findIndex(o => o.name === defaultValue || o.value === defaultValue);
863
1241
  if (activeIdx === -1) activeIdx = 0;
@@ -867,7 +1245,7 @@ async function promptMenu(rl, label, options, defaultValue) {
867
1245
  stdout.write(`${colors.bold}${colors.magenta}? ${label}${colors.reset}\n`);
868
1246
  for (let i = 0; i < options.length; i++) {
869
1247
  if (i === activeIdx) {
870
- stdout.write(` ${colors.cyan} ${options[i].name}${colors.reset}\n`);
1248
+ stdout.write(` ${colors.cyan}> ${options[i].name}${colors.reset}\n`);
871
1249
  } else {
872
1250
  stdout.write(` ${options[i].name}\n`);
873
1251
  }
@@ -900,9 +1278,7 @@ async function promptMenu(rl, label, options, defaultValue) {
900
1278
  } else if (key && (key.name === "return" || key.name === "enter")) {
901
1279
  stdout.write("\x1B[?25h"); // Show cursor
902
1280
  stdin.removeListener("keypress", onKeypress);
903
- if (stdin.isTTY) {
904
- stdin.setRawMode(false);
905
- }
1281
+ stdin.setRawMode(false);
906
1282
  stdout.write(`\n`);
907
1283
  rl.resume(); // Resume readline interface
908
1284
  resolve(options[activeIdx].value);
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "git-multiverse",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Clone-free Multiverse Docker Hub launcher.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "git-multiverse": "./bin/git-multiverse.mjs"
8
8
  },
9
9
  "scripts": {
10
+ "build:native": "node ./scripts/build-native.mjs",
10
11
  "test": "node --test",
11
12
  "doctor": "node ./bin/git-multiverse.mjs doctor"
12
13
  },
@@ -1,179 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
-
7
- import {
8
- buildMcpClientConfig,
9
- cmdDoctor,
10
- cmdMcpSetup,
11
- cmdSetup,
12
- createSetupConfig,
13
- getStatePaths,
14
- parseEnvFile,
15
- renderComposeFile,
16
- renderMcpClientConfig,
17
- writeStateFiles
18
- } from "../lib/app.mjs";
19
-
20
- test("writeStateFiles writes env mode 0600 with expected keys", () => {
21
- const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
22
- const config = createSetupConfig({
23
- workspace: "/tmp/repos",
24
- "admin-password": "admin-secret",
25
- "neo4j-password": "neo4j-secret",
26
- "image-tag": "test"
27
- }, { homeDir });
28
-
29
- const paths = writeStateFiles(config, { homeDir });
30
- const envText = fs.readFileSync(paths.envPath, "utf8");
31
- const mode = fs.statSync(paths.envPath).mode & 0o777;
32
-
33
- if (os.platform() !== "win32") {
34
- assert.equal(mode, 0o600);
35
- }
36
- assert.match(envText, /^MULTIVERSE_ADMIN_PASSWORD=admin-secret/m);
37
- assert.match(envText, /^MULTIVERSE_MCP_ALLOW_ANONYMOUS=false/m);
38
- assert.match(envText, /^MULTIVERSE_IMAGE_TAG=test/m);
39
- });
40
-
41
- test("createSetupConfig correctly pre-fills and respects existingConfig", () => {
42
- const existing = {
43
- MULTIVERSE_HOST_WORKSPACE_DIR: "/my/previous/workspace",
44
- MULTIVERSE_NEO4J_MODE: "external",
45
- MULTIVERSE_ADMIN_PASSWORD: "my-old-pass",
46
- MULTIVERSE_NEO4J_PASSWORD: "neo-old-pass",
47
- MULTIVERSE_ENABLE_AI: "true",
48
- NINEROUTER_API_BASE_URL: "https://api.custom-ai.com/v1",
49
- NINEROUTER_MODEL_NAME: "custom-gpt-5",
50
- NINEROUTER_API_KEY: "custom-key",
51
- MULTIVERSE_ENABLE_EMBEDDINGS: "true",
52
- EMBEDDING_API_BASE_URL: "https://api.custom-embed.com/v1",
53
- EMBEDDING_MODEL_NAME: "custom-embed-3",
54
- EMBEDDING_API_KEY: "embed-key",
55
- MULTIVERSE_NEO4J_URI: "neo4j://old-uri:7687",
56
- MULTIVERSE_NEO4J_USER: "old-user"
57
- };
58
-
59
- const config = createSetupConfig({}, {}, existing);
60
-
61
- assert.equal(config.MULTIVERSE_HOST_WORKSPACE_DIR, "/my/previous/workspace");
62
- assert.equal(config.MULTIVERSE_NEO4J_MODE, "external");
63
- assert.equal(config.MULTIVERSE_ADMIN_PASSWORD, "my-old-pass");
64
- assert.equal(config.MULTIVERSE_NEO4J_PASSWORD, "neo-old-pass");
65
- assert.equal(config.MULTIVERSE_ENABLE_AI, "true");
66
- assert.equal(config.NINEROUTER_API_BASE_URL, "https://api.custom-ai.com/v1");
67
- assert.equal(config.NINEROUTER_MODEL_NAME, "custom-gpt-5");
68
- assert.equal(config.NINEROUTER_API_KEY, "custom-key");
69
- assert.equal(config.MULTIVERSE_ENABLE_EMBEDDINGS, "true");
70
- assert.equal(config.EMBEDDING_API_BASE_URL, "https://api.custom-embed.com/v1");
71
- assert.equal(config.EMBEDDING_MODEL_NAME, "custom-embed-3");
72
- assert.equal(config.EMBEDDING_API_KEY, "embed-key");
73
- assert.equal(config.MULTIVERSE_NEO4J_URI, "neo4j://old-uri:7687");
74
- assert.equal(config.MULTIVERSE_NEO4J_USER, "old-user");
75
- });
76
-
77
- test("renderComposeFile references Docker Hub images and localhost binds", () => {
78
- const config = createSetupConfig({
79
- workspace: "/tmp/repos",
80
- "admin-password": "admin-secret",
81
- "neo4j-password": "neo4j-secret"
82
- });
83
-
84
- const compose = renderComposeFile(config);
85
- assert.match(compose, /image: anhdt5\/git-multiverse:latest/);
86
- assert.match(compose, /image: neo4j:5\.26-community/);
87
- assert.match(compose, /127\.0\.0\.1:18081:18081/);
88
- assert.match(compose, /127\.0\.0\.1:7474:7474/);
89
- assert.match(compose, /127\.0\.0\.1:7687:7687/);
90
- });
91
-
92
- test("non-interactive setup produces deterministic files in temp HOME", async () => {
93
- const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
94
- const output = [];
95
-
96
- await cmdSetup({
97
- "non-interactive": true,
98
- "neo4j-mode": "external",
99
- workspace: "/tmp/repos",
100
- "admin-password": "admin-secret",
101
- "neo4j-uri": "neo4j://db.example:7687",
102
- "neo4j-user": "neo4j",
103
- "neo4j-password": "neo4j-secret",
104
- "neo4j-database": "neo4j",
105
- "image-tag": "1.2.3"
106
- }, { homeDir, log: (line) => output.push(line) });
107
-
108
- const { envPath, composePath } = getStatePaths({ homeDir });
109
- const envText = fs.readFileSync(envPath, "utf8");
110
- const composeText = fs.readFileSync(composePath, "utf8");
111
- const parsed = parseEnvFile(envText);
112
-
113
- assert.equal(parsed.MULTIVERSE_NEO4J_MODE, "external");
114
- assert.equal(parsed.MULTIVERSE_NEO4J_URI, "neo4j://db.example:7687");
115
- assert.match(composeText, /image: anhdt5\/git-multiverse:1\.2\.3/);
116
- assert.doesNotMatch(composeText, /neo4j:5\.26-community/);
117
- assert.ok(output.some((line) => String(line).includes(envPath)));
118
- });
119
-
120
- test("doctor reports missing docker gracefully", async () => {
121
- const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-multiverse-home-"));
122
- const config = createSetupConfig({
123
- workspace: "/tmp/repos",
124
- "admin-password": "admin-secret",
125
- "neo4j-password": "neo4j-secret"
126
- }, { homeDir });
127
- writeStateFiles(config, { homeDir });
128
-
129
- const lines = [];
130
- let exitCode = 0;
131
- await cmdDoctor({
132
- homeDir,
133
- log: (line) => lines.push(line),
134
- exit: (code) => {
135
- exitCode = code;
136
- },
137
- spawnSyncImpl: () => ({ status: 1, stderr: "not found" })
138
- });
139
-
140
- assert.equal(exitCode, 1);
141
- assert.ok(lines.some((line) => String(line).includes("FAIL docker: missing docker CLI")));
142
- assert.ok(lines.some((line) => String(line).includes("FAIL docker compose: missing docker compose plugin")));
143
- });
144
-
145
- test("buildMcpClientConfig targets the /mcp endpoint with a bearer header", () => {
146
- const cursor = buildMcpClientConfig("cursor", "http://127.0.0.1:18081/", "key-123");
147
- assert.equal(cursor.kind, "json");
148
- assert.equal(cursor.config.mcpServers.multiverse.url, "http://127.0.0.1:18081/mcp");
149
- assert.equal(cursor.config.mcpServers.multiverse.headers.Authorization, "Bearer key-123");
150
-
151
- const opencode = buildMcpClientConfig("opencode", "http://127.0.0.1:18081", "key-123");
152
- assert.equal(opencode.config.mcp.multiverse.type, "remote");
153
- assert.equal(opencode.config.mcp.multiverse.url, "http://127.0.0.1:18081/mcp");
154
-
155
- const claude = buildMcpClientConfig("claude", "http://127.0.0.1:18081", "key-123");
156
- assert.equal(claude.kind, "shell");
157
- assert.match(claude.command, /claude mcp add --transport http multiverse http:\/\/127\.0\.0\.1:18081\/mcp/);
158
- assert.match(claude.command, /Bearer key-123/);
159
-
160
- const codex = buildMcpClientConfig("codex", "http://127.0.0.1:18081", "key-123");
161
- assert.equal(codex.kind, "toml");
162
- assert.match(codex.config, /\[mcp_servers\.multiverse\]/);
163
- });
164
-
165
- test("buildMcpClientConfig uses placeholder when key is missing and rejects unknown editors", () => {
166
- const snippet = renderMcpClientConfig("windsurf", "http://127.0.0.1:18081", "");
167
- assert.match(snippet, /Bearer <MCP_KEY>/);
168
- assert.throws(() => buildMcpClientConfig("emacs", "http://127.0.0.1:18081", "k"), /Unknown editor/);
169
- });
170
-
171
- test("cmdMcpSetup prints a snippet for every supported editor", async () => {
172
- const lines = [];
173
- await cmdMcpSetup({ "mcp-key": "key-123" }, { log: (line) => lines.push(line) });
174
- const text = lines.join("\n");
175
- for (const editor of ["claude", "cursor", "codex", "opencode", "windsurf"]) {
176
- assert.ok(text.includes(`# ${editor}`), `expected snippet for ${editor}`);
177
- }
178
- assert.ok(text.includes("Bearer key-123"));
179
- });