docdex 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,885 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const net = require("node:net");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const readline = require("node:readline");
9
+ const { spawn, spawnSync } = require("node:child_process");
10
+
11
+ const { detectPlatformKey, UnsupportedPlatformError } = require("./platform");
12
+
13
+ const DEFAULT_HOST = "127.0.0.1";
14
+ const DEFAULT_PORT_PRIMARY = 3000;
15
+ const DEFAULT_PORT_FALLBACK = 3210;
16
+ const STARTUP_FAILURE_MARKER = "startup_registration_failed.json";
17
+ const DEFAULT_OLLAMA_MODEL = "nomic-embed-text";
18
+ const DEFAULT_OLLAMA_CHAT_MODEL = "phi3.5:3.8b";
19
+ const DEFAULT_OLLAMA_CHAT_MODEL_SIZE_GIB = 2.2;
20
+
21
+ function defaultConfigPath() {
22
+ return path.join(os.homedir(), ".docdex", "config.toml");
23
+ }
24
+
25
+ function daemonRootPath() {
26
+ return path.join(os.homedir(), ".docdex", "daemon_root");
27
+ }
28
+
29
+ function stateDir() {
30
+ return path.join(os.homedir(), ".docdex", "state");
31
+ }
32
+
33
+ function configUrlForPort(port) {
34
+ return `http://localhost:${port}/sse`;
35
+ }
36
+
37
+ function isPortAvailable(port, host) {
38
+ return new Promise((resolve) => {
39
+ const server = net.createServer();
40
+ server.unref();
41
+ server.once("error", () => resolve(false));
42
+ server.once("listening", () => {
43
+ server.close(() => resolve(true));
44
+ });
45
+ server.listen(port, host);
46
+ });
47
+ }
48
+
49
+ async function pickAvailablePort(host, preferred) {
50
+ for (const port of preferred) {
51
+ if (await isPortAvailable(port, host)) return port;
52
+ }
53
+ return new Promise((resolve, reject) => {
54
+ const server = net.createServer();
55
+ server.unref();
56
+ server.once("error", reject);
57
+ server.once("listening", () => {
58
+ const addr = server.address();
59
+ server.close(() => resolve(addr.port));
60
+ });
61
+ server.listen(0, host);
62
+ });
63
+ }
64
+
65
+ function parseServerBind(contents) {
66
+ let inServer = false;
67
+ const lines = contents.split(/\r?\n/);
68
+ for (const line of lines) {
69
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
70
+ if (section) {
71
+ inServer = section[1].trim() === "server";
72
+ continue;
73
+ }
74
+ if (!inServer) continue;
75
+ const match = line.match(/^\s*http_bind_addr\s*=\s*["']?([^"']+)["']?/);
76
+ if (match) return match[1].trim();
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function upsertServerConfig(contents, httpBindAddr) {
82
+ const lines = contents.split(/\r?\n/);
83
+ const output = [];
84
+ let inServer = false;
85
+ let foundServer = false;
86
+ let updatedBind = false;
87
+ let updatedEnable = false;
88
+
89
+ for (let idx = 0; idx < lines.length; idx += 1) {
90
+ const line = lines[idx];
91
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
92
+ if (section) {
93
+ if (inServer && (!updatedBind || !updatedEnable)) {
94
+ if (!updatedBind) output.push(`http_bind_addr = "${httpBindAddr}"`);
95
+ if (!updatedEnable) output.push("enable_mcp = true");
96
+ }
97
+ inServer = section[1].trim() === "server";
98
+ if (inServer) foundServer = true;
99
+ output.push(line);
100
+ continue;
101
+ }
102
+ if (inServer) {
103
+ if (/^\s*http_bind_addr\s*=/.test(line)) {
104
+ output.push(`http_bind_addr = "${httpBindAddr}"`);
105
+ updatedBind = true;
106
+ continue;
107
+ }
108
+ if (/^\s*enable_mcp\s*=/.test(line)) {
109
+ output.push("enable_mcp = true");
110
+ updatedEnable = true;
111
+ continue;
112
+ }
113
+ }
114
+ output.push(line);
115
+ }
116
+
117
+ if (foundServer) {
118
+ if (!updatedBind) output.push(`http_bind_addr = "${httpBindAddr}"`);
119
+ if (!updatedEnable) output.push("enable_mcp = true");
120
+ } else {
121
+ if (output.length && output[output.length - 1].trim()) output.push("");
122
+ output.push("[server]");
123
+ output.push(`http_bind_addr = "${httpBindAddr}"`);
124
+ output.push("enable_mcp = true");
125
+ }
126
+
127
+ return output.join("\n");
128
+ }
129
+
130
+ function readJson(pathname) {
131
+ try {
132
+ if (!fs.existsSync(pathname)) return { value: {}, exists: false };
133
+ const raw = fs.readFileSync(pathname, "utf8");
134
+ if (!raw.trim()) return { value: {}, exists: true };
135
+ return { value: JSON.parse(raw), exists: true };
136
+ } catch {
137
+ return { value: {}, exists: true };
138
+ }
139
+ }
140
+
141
+ function writeJson(pathname, value) {
142
+ fs.mkdirSync(path.dirname(pathname), { recursive: true });
143
+ fs.writeFileSync(pathname, JSON.stringify(value, null, 2) + "\n");
144
+ }
145
+
146
+ function upsertMcpServerJson(pathname, url) {
147
+ const { value } = readJson(pathname);
148
+ if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
149
+ const root = value;
150
+ if (!root.mcpServers || typeof root.mcpServers !== "object" || Array.isArray(root.mcpServers)) {
151
+ root.mcpServers = {};
152
+ }
153
+ const current = root.mcpServers.docdex;
154
+ if (current && current.url === url) return false;
155
+ root.mcpServers.docdex = { url };
156
+ writeJson(pathname, root);
157
+ return true;
158
+ }
159
+
160
+ function upsertCodexConfig(pathname, url) {
161
+ let contents = "";
162
+ if (fs.existsSync(pathname)) {
163
+ contents = fs.readFileSync(pathname, "utf8");
164
+ }
165
+ if (/name\s*=\s*\"docdex\"/.test(contents) || /docdex/.test(contents) && /mcp_servers/.test(contents)) {
166
+ return false;
167
+ }
168
+ const block = [
169
+ "",
170
+ "[[mcp_servers]]",
171
+ 'name = "docdex"',
172
+ `url = "${url}"`,
173
+ "",
174
+ ].join("\n");
175
+ fs.mkdirSync(path.dirname(pathname), { recursive: true });
176
+ fs.writeFileSync(pathname, contents + block);
177
+ return true;
178
+ }
179
+
180
+ function clientConfigPaths() {
181
+ const home = os.homedir();
182
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
183
+ const userProfile = process.env.USERPROFILE || home;
184
+ switch (process.platform) {
185
+ case "win32":
186
+ return {
187
+ claude: path.join(appData, "Claude", "claude_desktop_config.json"),
188
+ cursor: path.join(userProfile, ".cursor", "mcp.json"),
189
+ codex: path.join(userProfile, ".codex", "config.toml")
190
+ };
191
+ case "darwin":
192
+ return {
193
+ claude: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
194
+ cursor: path.join(home, ".cursor", "mcp.json"),
195
+ codex: path.join(home, ".codex", "config.toml")
196
+ };
197
+ default:
198
+ return {
199
+ claude: path.join(home, ".config", "Claude", "claude_desktop_config.json"),
200
+ cursor: path.join(home, ".cursor", "mcp.json"),
201
+ codex: path.join(home, ".codex", "config.toml")
202
+ };
203
+ }
204
+ }
205
+
206
+ function resolveBinaryPath({ binaryPath } = {}) {
207
+ if (binaryPath && fs.existsSync(binaryPath)) return binaryPath;
208
+ try {
209
+ const platformKey = detectPlatformKey();
210
+ const candidate = path.join(__dirname, "..", "dist", platformKey, process.platform === "win32" ? "docdexd.exe" : "docdexd");
211
+ if (fs.existsSync(candidate)) return candidate;
212
+ } catch (err) {
213
+ if (!(err instanceof UnsupportedPlatformError)) throw err;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function ensureDaemonRoot() {
219
+ const root = daemonRootPath();
220
+ fs.mkdirSync(root, { recursive: true });
221
+ const readme = path.join(root, "README.md");
222
+ if (!fs.existsSync(readme)) {
223
+ fs.writeFileSync(readme, "# Docdex daemon root\n");
224
+ }
225
+ return root;
226
+ }
227
+
228
+ function parseEnvBool(value) {
229
+ if (value == null) return null;
230
+ const normalized = String(value).trim().toLowerCase();
231
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
232
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
233
+ return null;
234
+ }
235
+
236
+ function resolveOllamaInstallMode({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
237
+ const override = parseEnvBool(env.DOCDEX_OLLAMA_INSTALL);
238
+ if (override === true) return { mode: "install", reason: "env", interactive: false };
239
+ if (override === false) return { mode: "skip", reason: "env", interactive: false };
240
+ const hasTty = Boolean(stdin && stdout && stdin.isTTY && stdout.isTTY);
241
+ if (!hasTty) return { mode: "skip", reason: "non_interactive", interactive: false };
242
+ if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
243
+ return { mode: "prompt", reason: "interactive", interactive: true };
244
+ }
245
+
246
+ function resolveOllamaModelPromptMode({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
247
+ const override = parseEnvBool(env.DOCDEX_OLLAMA_MODEL_PROMPT);
248
+ if (override === true) return { mode: "prompt", reason: "env", interactive: true };
249
+ if (override === false) return { mode: "skip", reason: "env", interactive: false };
250
+ const assumeYes = parseEnvBool(env.DOCDEX_OLLAMA_MODEL_ASSUME_Y);
251
+ if (assumeYes === true) return { mode: "auto", reason: "env", interactive: false };
252
+ const hasTty = Boolean(stdin && stdout && stdin.isTTY && stdout.isTTY);
253
+ if (!hasTty) return { mode: "skip", reason: "non_interactive", interactive: false };
254
+ if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
255
+ return { mode: "prompt", reason: "interactive", interactive: true };
256
+ }
257
+
258
+ function parseOllamaListOutput(output) {
259
+ const lines = String(output || "").split(/\r?\n/);
260
+ const models = [];
261
+ for (const line of lines) {
262
+ const trimmed = line.trim();
263
+ if (!trimmed || /^name\b/i.test(trimmed)) continue;
264
+ const name = trimmed.split(/\s+/)[0];
265
+ if (name) models.push(name);
266
+ }
267
+ return models;
268
+ }
269
+
270
+ function listOllamaModels({ runner = spawnSync } = {}) {
271
+ const result = runner("ollama", ["list"], { stdio: "pipe" });
272
+ if (result.error || result.status !== 0) return null;
273
+ return parseOllamaListOutput(result.stdout);
274
+ }
275
+
276
+ function formatGiB(bytes) {
277
+ if (!Number.isFinite(bytes) || bytes <= 0) return "unknown";
278
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
279
+ }
280
+
281
+ function getDiskFreeBytesUnix() {
282
+ if (typeof fs.statfsSync !== "function") return null;
283
+ try {
284
+ const stats = fs.statfsSync(os.homedir());
285
+ return Number(stats.bavail) * Number(stats.bsize);
286
+ } catch {
287
+ try {
288
+ const stats = fs.statfsSync("/");
289
+ return Number(stats.bavail) * Number(stats.bsize);
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+ }
295
+
296
+ function parsePowerShellFreeBytes(output) {
297
+ const trimmed = String(output || "").trim();
298
+ const value = Number.parseFloat(trimmed);
299
+ return Number.isFinite(value) ? value : null;
300
+ }
301
+
302
+ function parseWmicFreeBytes(output) {
303
+ const lines = String(output || "").split(/\r?\n/);
304
+ for (const line of lines) {
305
+ const match = line.match(/FreeSpace=(\d+)/i);
306
+ if (match) return Number(match[1]);
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function getDiskFreeBytesWindows() {
312
+ if (isCommandAvailable("powershell", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major"])) {
313
+ const result = spawnSync(
314
+ "powershell",
315
+ [
316
+ "-NoProfile",
317
+ "-Command",
318
+ "(Get-PSDrive -Name $env:SystemDrive.TrimEnd(':')).Free"
319
+ ],
320
+ { stdio: "pipe" }
321
+ );
322
+ const parsed = parsePowerShellFreeBytes(result.stdout);
323
+ if (parsed != null) return parsed;
324
+ }
325
+ if (isCommandAvailable("wmic", ["/?"])) {
326
+ const drive = (process.env.SystemDrive || "C:").toUpperCase();
327
+ const result = spawnSync(
328
+ "wmic",
329
+ ["logicaldisk", "where", `DeviceID='${drive}'`, "get", "FreeSpace", "/value"],
330
+ { stdio: "pipe" }
331
+ );
332
+ return parseWmicFreeBytes(result.stdout);
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function getDiskFreeBytes() {
338
+ if (process.platform === "win32") return getDiskFreeBytesWindows();
339
+ return getDiskFreeBytesUnix();
340
+ }
341
+
342
+ function normalizeModelName(name) {
343
+ return String(name || "").trim();
344
+ }
345
+
346
+ function readLlmDefaultModel(contents) {
347
+ let inLlm = false;
348
+ const lines = String(contents || "").split(/\r?\n/);
349
+ for (const line of lines) {
350
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
351
+ if (section) {
352
+ inLlm = section[1].trim() === "llm";
353
+ continue;
354
+ }
355
+ if (!inLlm) continue;
356
+ const match = line.match(/^\s*default_model\s*=\s*\"([^\"]+)\"/);
357
+ if (match) return match[1];
358
+ }
359
+ return null;
360
+ }
361
+
362
+ function upsertLlmDefaultModel(contents, model) {
363
+ const lines = String(contents || "").split(/\r?\n/);
364
+ const output = [];
365
+ let inLlm = false;
366
+ let foundLlm = false;
367
+ let updated = false;
368
+
369
+ for (const line of lines) {
370
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
371
+ if (section) {
372
+ if (inLlm && !updated) {
373
+ output.push(`default_model = \"${model}\"`);
374
+ updated = true;
375
+ }
376
+ inLlm = section[1].trim() === "llm";
377
+ if (inLlm) foundLlm = true;
378
+ output.push(line);
379
+ continue;
380
+ }
381
+ if (inLlm) {
382
+ if (/^\s*default_model\s*=/.test(line)) {
383
+ output.push(`default_model = \"${model}\"`);
384
+ updated = true;
385
+ continue;
386
+ }
387
+ }
388
+ output.push(line);
389
+ }
390
+
391
+ if (foundLlm) {
392
+ if (!updated) output.push(`default_model = \"${model}\"`);
393
+ } else {
394
+ if (output.length && output[output.length - 1].trim()) output.push("");
395
+ output.push("[llm]");
396
+ output.push(`default_model = \"${model}\"`);
397
+ }
398
+ return output.join("\n");
399
+ }
400
+
401
+ function isCommandAvailable(command, args = ["--version"]) {
402
+ const result = spawnSync(command, args, { stdio: "ignore" });
403
+ if (result.error) return false;
404
+ return true;
405
+ }
406
+
407
+ function isOllamaAvailable() {
408
+ return isCommandAvailable("ollama", ["--version"]);
409
+ }
410
+
411
+ function promptYesNo(question, { defaultYes = true, stdin = process.stdin, stdout = process.stdout } = {}) {
412
+ return new Promise((resolve) => {
413
+ const rl = readline.createInterface({ input: stdin, output: stdout });
414
+ rl.question(question, (answer) => {
415
+ rl.close();
416
+ const normalized = String(answer || "").trim().toLowerCase();
417
+ if (!normalized) return resolve(defaultYes);
418
+ resolve(["y", "yes"].includes(normalized));
419
+ });
420
+ });
421
+ }
422
+
423
+ function promptInput(question, { stdin = process.stdin, stdout = process.stdout } = {}) {
424
+ return new Promise((resolve) => {
425
+ const rl = readline.createInterface({ input: stdin, output: stdout });
426
+ rl.question(question, (answer) => {
427
+ rl.close();
428
+ resolve(String(answer || "").trim());
429
+ });
430
+ });
431
+ }
432
+
433
+ function runInstallCommand(command, args, { logger, interactive } = {}) {
434
+ const options = { stdio: interactive ? "inherit" : "pipe" };
435
+ const result = spawnSync(command, args, options);
436
+ if (result.error) {
437
+ logger?.warn?.(`[docdex] ${command} failed: ${result.error.message || result.error}`);
438
+ return false;
439
+ }
440
+ if (result.status !== 0) {
441
+ const stderr = result.stderr ? String(result.stderr).trim() : "";
442
+ logger?.warn?.(`[docdex] ${command} exited with ${result.status}${stderr ? `: ${stderr}` : ""}`);
443
+ return false;
444
+ }
445
+ return true;
446
+ }
447
+
448
+ function installOllama({ logger, interactive } = {}) {
449
+ if (process.platform === "darwin") {
450
+ if (!isCommandAvailable("brew")) {
451
+ logger?.warn?.("[docdex] Homebrew not found; install Ollama from https://ollama.com/download");
452
+ return false;
453
+ }
454
+ return runInstallCommand("brew", ["install", "ollama"], { logger, interactive });
455
+ }
456
+ if (process.platform === "linux") {
457
+ if (isCommandAvailable("curl")) {
458
+ return runInstallCommand("sh", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], {
459
+ logger,
460
+ interactive
461
+ });
462
+ }
463
+ if (isCommandAvailable("wget")) {
464
+ return runInstallCommand("sh", ["-c", "wget -qO- https://ollama.com/install.sh | sh"], {
465
+ logger,
466
+ interactive
467
+ });
468
+ }
469
+ logger?.warn?.("[docdex] curl or wget not found; install Ollama from https://ollama.com/download");
470
+ return false;
471
+ }
472
+ if (process.platform === "win32") {
473
+ if (!isCommandAvailable("winget", ["--version"])) {
474
+ logger?.warn?.("[docdex] winget not found; install Ollama from https://ollama.com/download");
475
+ return false;
476
+ }
477
+ return runInstallCommand(
478
+ "winget",
479
+ ["install", "-e", "--id", "Ollama.Ollama", "--accept-package-agreements", "--accept-source-agreements"],
480
+ { logger, interactive }
481
+ );
482
+ }
483
+ logger?.warn?.("[docdex] unsupported platform; install Ollama from https://ollama.com/download");
484
+ return false;
485
+ }
486
+
487
+ function pullOllamaModel(model, { logger, interactive, runner = spawnSync } = {}) {
488
+ const result = runner("ollama", ["pull", model], { stdio: interactive ? "inherit" : "pipe" });
489
+ if (result.error) {
490
+ logger?.warn?.(`[docdex] ollama pull failed: ${result.error.message || result.error}`);
491
+ return false;
492
+ }
493
+ if (result.status !== 0) {
494
+ const stderr = result.stderr ? String(result.stderr).trim() : "";
495
+ logger?.warn?.(`[docdex] ollama pull exited with ${result.status}${stderr ? `: ${stderr}` : ""}`);
496
+ return false;
497
+ }
498
+ return true;
499
+ }
500
+
501
+ async function maybeInstallOllama({ logger, env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
502
+ if (isOllamaAvailable()) return { status: "available" };
503
+ const decision = resolveOllamaInstallMode({ env, stdin, stdout });
504
+ if (decision.mode === "skip") return { status: "skipped", reason: decision.reason };
505
+ if (decision.mode === "prompt") {
506
+ const answer = await promptYesNo(
507
+ `[docdex] Ollama not found. Install Ollama and ${DEFAULT_OLLAMA_MODEL}? [Y/n] `,
508
+ { defaultYes: true, stdin, stdout }
509
+ );
510
+ if (!answer) {
511
+ logger?.warn?.("[docdex] Skipping Ollama install. Run `docdexd llm-setup` later if needed.");
512
+ return { status: "declined" };
513
+ }
514
+ }
515
+ logger?.warn?.("[docdex] Installing Ollama...");
516
+ const installed = installOllama({ logger, interactive: decision.interactive });
517
+ if (!installed) {
518
+ logger?.warn?.("[docdex] Ollama install failed; see https://ollama.com/download");
519
+ return { status: "failed" };
520
+ }
521
+ if (!isOllamaAvailable()) {
522
+ logger?.warn?.("[docdex] Ollama installed but not found on PATH. Restart your shell.");
523
+ return { status: "failed" };
524
+ }
525
+ const model = String(env.DOCDEX_OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
526
+ const pulled = pullOllamaModel(model, { logger, interactive: decision.interactive });
527
+ if (!pulled) {
528
+ logger?.warn?.(`[docdex] Ollama installed but model pull failed. Run: ollama pull ${model}`);
529
+ return { status: "partial" };
530
+ }
531
+ logger?.warn?.(`[docdex] Ollama ready with model ${model}.`);
532
+ return { status: "installed" };
533
+ }
534
+
535
+ function updateDefaultModelConfig(configPath, model, logger) {
536
+ if (!configPath) return false;
537
+ const normalized = normalizeModelName(model);
538
+ if (!normalized) return false;
539
+ let contents = "";
540
+ if (fs.existsSync(configPath)) {
541
+ contents = fs.readFileSync(configPath, "utf8");
542
+ }
543
+ const current = normalizeModelName(readLlmDefaultModel(contents));
544
+ if (current && current === normalized) return false;
545
+ const next = upsertLlmDefaultModel(contents, normalized);
546
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
547
+ fs.writeFileSync(configPath, next);
548
+ logger?.warn?.(`[docdex] set default model to ${normalized} in ${configPath}`);
549
+ return true;
550
+ }
551
+
552
+ async function maybePromptOllamaModel({
553
+ logger,
554
+ configPath,
555
+ env = process.env,
556
+ stdin = process.stdin,
557
+ stdout = process.stdout
558
+ } = {}) {
559
+ if (!isOllamaAvailable()) return { status: "skipped", reason: "ollama_missing" };
560
+
561
+ const forced = normalizeModelName(env.DOCDEX_OLLAMA_MODEL);
562
+ if (forced) {
563
+ const installed = listOllamaModels() || [];
564
+ const forcedLower = forced.toLowerCase();
565
+ const hasForced = installed.some((model) => normalizeModelName(model).toLowerCase() === forcedLower);
566
+ if (!hasForced) {
567
+ const pulled = pullOllamaModel(forced, { logger, interactive: false });
568
+ if (!pulled) return { status: "failed", reason: "pull_failed" };
569
+ }
570
+ updateDefaultModelConfig(configPath, forced, logger);
571
+ return { status: "forced", model: forced };
572
+ }
573
+
574
+ const decision = resolveOllamaModelPromptMode({ env, stdin, stdout });
575
+ if (decision.mode === "skip") return { status: "skipped", reason: decision.reason };
576
+
577
+ const installed = listOllamaModels();
578
+ if (!installed) {
579
+ logger?.warn?.("[docdex] ollama list failed; skipping model prompt");
580
+ return { status: "skipped", reason: "list_failed" };
581
+ }
582
+
583
+ const phiModel = DEFAULT_OLLAMA_CHAT_MODEL;
584
+ const freeBytes = getDiskFreeBytes();
585
+ const freeText = formatGiB(freeBytes);
586
+ const sizeText = `${DEFAULT_OLLAMA_CHAT_MODEL_SIZE_GIB.toFixed(1)} GB`;
587
+
588
+ const configContents = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
589
+ const configDefault = normalizeModelName(readLlmDefaultModel(configContents));
590
+ const envDefault = normalizeModelName(env.DOCDEX_OLLAMA_DEFAULT_MODEL);
591
+ const defaultChoice = envDefault || configDefault || null;
592
+
593
+ if (installed.length === 0) {
594
+ if (decision.mode === "auto") {
595
+ const pulled = pullOllamaModel(phiModel, { logger, interactive: false });
596
+ if (!pulled) return { status: "failed", reason: "pull_failed" };
597
+ updateDefaultModelConfig(configPath, phiModel, logger);
598
+ return { status: "installed", model: phiModel };
599
+ }
600
+ stdout.write(
601
+ `[docdex] Ollama has no models installed. Free space: ${freeText}. ` +
602
+ `${phiModel} uses ~${sizeText}.\n`
603
+ );
604
+ const accept = await promptYesNo(
605
+ `[docdex] Install ${phiModel} now? [Y/n] `,
606
+ { defaultYes: true, stdin, stdout }
607
+ );
608
+ if (!accept) return { status: "declined" };
609
+ const pulled = pullOllamaModel(phiModel, { logger, interactive: true });
610
+ if (!pulled) return { status: "failed", reason: "pull_failed" };
611
+ updateDefaultModelConfig(configPath, phiModel, logger);
612
+ return { status: "installed", model: phiModel };
613
+ }
614
+
615
+ const normalizedInstalled = installed.map(normalizeModelName);
616
+ const installedLower = normalizedInstalled.map((model) => model.toLowerCase());
617
+ const hasPhi = installedLower.includes(phiModel.toLowerCase());
618
+ const selectionDefault = defaultChoice && installedLower.includes(defaultChoice.toLowerCase())
619
+ ? defaultChoice
620
+ : normalizedInstalled[0];
621
+
622
+ if (decision.mode === "auto") {
623
+ if (selectionDefault) {
624
+ updateDefaultModelConfig(configPath, selectionDefault, logger);
625
+ return { status: "selected", model: selectionDefault };
626
+ }
627
+ return { status: "skipped", reason: "no_models" };
628
+ }
629
+
630
+ stdout.write("[docdex] Ollama models detected:\n");
631
+ normalizedInstalled.forEach((model, idx) => {
632
+ const marker = model === selectionDefault ? " (default)" : "";
633
+ stdout.write(` ${idx + 1}) ${model}${marker}\n`);
634
+ });
635
+ if (!hasPhi) {
636
+ stdout.write(` I) Install ${phiModel} (~${sizeText}, free ${freeText})\n`);
637
+ }
638
+ stdout.write(" S) Skip\n");
639
+
640
+ const answer = await promptInput(
641
+ `[docdex] Select default model [${selectionDefault}]: `,
642
+ { stdin, stdout }
643
+ );
644
+ const normalizedAnswer = normalizeModelName(answer);
645
+ const answerLower = normalizedAnswer.toLowerCase();
646
+ if (!answer) {
647
+ updateDefaultModelConfig(configPath, selectionDefault, logger);
648
+ return { status: "selected", model: selectionDefault };
649
+ }
650
+ if (answerLower === "s" || answerLower === "skip") {
651
+ return { status: "skipped", reason: "user_skip" };
652
+ }
653
+ if ((answerLower === "i" || answerLower === "install") && !hasPhi) {
654
+ const pulled = pullOllamaModel(phiModel, { logger, interactive: true });
655
+ if (!pulled) return { status: "failed", reason: "pull_failed" };
656
+ updateDefaultModelConfig(configPath, phiModel, logger);
657
+ return { status: "installed", model: phiModel };
658
+ }
659
+ const numeric = Number.parseInt(answerLower, 10);
660
+ if (Number.isFinite(numeric) && numeric >= 1 && numeric <= normalizedInstalled.length) {
661
+ const selected = normalizedInstalled[numeric - 1];
662
+ updateDefaultModelConfig(configPath, selected, logger);
663
+ return { status: "selected", model: selected };
664
+ }
665
+ const matchedIndex = installedLower.indexOf(answerLower);
666
+ if (matchedIndex !== -1) {
667
+ const selected = normalizedInstalled[matchedIndex];
668
+ updateDefaultModelConfig(configPath, selected, logger);
669
+ return { status: "selected", model: selected };
670
+ }
671
+ logger?.warn?.("[docdex] Unrecognized selection; skipping model update.");
672
+ return { status: "skipped", reason: "invalid_selection" };
673
+ }
674
+
675
+ function registerStartup({ binaryPath, port, repoRoot, logger }) {
676
+ if (!binaryPath) return { ok: false, reason: "missing_binary" };
677
+ const args = [
678
+ "daemon",
679
+ "--repo",
680
+ repoRoot,
681
+ "--host",
682
+ DEFAULT_HOST,
683
+ "--port",
684
+ String(port),
685
+ "--log",
686
+ "warn",
687
+ "--secure-mode=false"
688
+ ];
689
+
690
+ if (process.platform === "darwin") {
691
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.docdex.daemon.plist");
692
+ const logDir = path.join(os.homedir(), ".docdex", "logs");
693
+ fs.mkdirSync(logDir, { recursive: true });
694
+ const programArgs = [binaryPath, ...args];
695
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>\n` +
696
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
697
+ `<plist version="1.0">\n` +
698
+ `<dict>\n` +
699
+ ` <key>Label</key>\n` +
700
+ ` <string>com.docdex.daemon</string>\n` +
701
+ ` <key>ProgramArguments</key>\n` +
702
+ ` <array>\n` +
703
+ programArgs.map((arg) => ` <string>${arg}</string>\n`).join("") +
704
+ ` </array>\n` +
705
+ ` <key>RunAtLoad</key>\n` +
706
+ ` <true/>\n` +
707
+ ` <key>KeepAlive</key>\n` +
708
+ ` <true/>\n` +
709
+ ` <key>StandardOutPath</key>\n` +
710
+ ` <string>${path.join(logDir, "daemon.out.log")}</string>\n` +
711
+ ` <key>StandardErrorPath</key>\n` +
712
+ ` <string>${path.join(logDir, "daemon.err.log")}</string>\n` +
713
+ `</dict>\n` +
714
+ `</plist>\n`;
715
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
716
+ fs.writeFileSync(plistPath, plist);
717
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
718
+ const bootstrap = uid != null
719
+ ? spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath])
720
+ : spawnSync("launchctl", ["load", "-w", plistPath]);
721
+ if (bootstrap.status === 0) return { ok: true };
722
+ const fallback = spawnSync("launchctl", ["load", "-w", plistPath]);
723
+ if (fallback.status === 0) return { ok: true };
724
+ logger?.warn?.(`[docdex] launchctl failed: ${bootstrap.stderr || fallback.stderr || "unknown error"}`);
725
+ return { ok: false, reason: "launchctl_failed" };
726
+ }
727
+
728
+ if (process.platform === "linux") {
729
+ const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
730
+ const unitPath = path.join(systemdDir, "docdexd.service");
731
+ fs.mkdirSync(systemdDir, { recursive: true });
732
+ const unit = [
733
+ "[Unit]",
734
+ "Description=Docdex daemon",
735
+ "After=network.target",
736
+ "",
737
+ "[Service]",
738
+ `ExecStart=${binaryPath} ${args.join(" ")}`,
739
+ "Restart=always",
740
+ "RestartSec=2",
741
+ "",
742
+ "[Install]",
743
+ "WantedBy=default.target",
744
+ ""
745
+ ].join("\n");
746
+ fs.writeFileSync(unitPath, unit);
747
+ const reload = spawnSync("systemctl", ["--user", "daemon-reload"]);
748
+ const enable = spawnSync("systemctl", ["--user", "enable", "--now", "docdexd.service"]);
749
+ if (reload.status === 0 && enable.status === 0) return { ok: true };
750
+ logger?.warn?.(`[docdex] systemd failed: ${enable.stderr || reload.stderr || "unknown error"}`);
751
+ return { ok: false, reason: "systemd_failed" };
752
+ }
753
+
754
+ if (process.platform === "win32") {
755
+ const taskName = "Docdex Daemon";
756
+ const taskArgs = `"${binaryPath}" ${args.map((arg) => `"${arg}"`).join(" ")}`;
757
+ const create = spawnSync("schtasks", [
758
+ "/Create",
759
+ "/F",
760
+ "/SC",
761
+ "ONLOGON",
762
+ "/RL",
763
+ "LIMITED",
764
+ "/TN",
765
+ taskName,
766
+ "/TR",
767
+ taskArgs
768
+ ]);
769
+ if (create.status === 0) {
770
+ spawnSync("schtasks", ["/Run", "/TN", taskName]);
771
+ return { ok: true };
772
+ }
773
+ logger?.warn?.(`[docdex] schtasks failed: ${create.stderr || "unknown error"}`);
774
+ return { ok: false, reason: "schtasks_failed" };
775
+ }
776
+
777
+ return { ok: false, reason: "unsupported_platform" };
778
+ }
779
+
780
+ function startDaemonNow({ binaryPath, port, repoRoot }) {
781
+ if (!binaryPath) return false;
782
+ const child = spawn(
783
+ binaryPath,
784
+ [
785
+ "daemon",
786
+ "--repo",
787
+ repoRoot,
788
+ "--host",
789
+ DEFAULT_HOST,
790
+ "--port",
791
+ String(port),
792
+ "--log",
793
+ "warn",
794
+ "--secure-mode=false"
795
+ ],
796
+ { stdio: "ignore", detached: true }
797
+ );
798
+ child.unref();
799
+ return true;
800
+ }
801
+
802
+ function recordStartupFailure(details) {
803
+ const markerPath = path.join(stateDir(), STARTUP_FAILURE_MARKER);
804
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
805
+ fs.writeFileSync(markerPath, JSON.stringify(details, null, 2));
806
+ }
807
+
808
+ function clearStartupFailure() {
809
+ const markerPath = path.join(stateDir(), STARTUP_FAILURE_MARKER);
810
+ if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath);
811
+ }
812
+
813
+ function startupFailureReported() {
814
+ return fs.existsSync(path.join(stateDir(), STARTUP_FAILURE_MARKER));
815
+ }
816
+
817
+ async function runPostInstallSetup({ binaryPath, logger } = {}) {
818
+ const log = logger || console;
819
+ const configPath = defaultConfigPath();
820
+ let existingConfig = "";
821
+ if (fs.existsSync(configPath)) {
822
+ existingConfig = fs.readFileSync(configPath, "utf8");
823
+ }
824
+ const configuredBind = existingConfig ? parseServerBind(existingConfig) : null;
825
+ let port;
826
+ if (process.env.DOCDEX_DAEMON_PORT) {
827
+ port = Number(process.env.DOCDEX_DAEMON_PORT);
828
+ } else if (configuredBind) {
829
+ const match = configuredBind.match(/:(\d+)$/);
830
+ port = match ? Number(match[1]) : null;
831
+ }
832
+ if (!port || Number.isNaN(port)) {
833
+ port = await pickAvailablePort(DEFAULT_HOST, [DEFAULT_PORT_PRIMARY, DEFAULT_PORT_FALLBACK]);
834
+ }
835
+
836
+ const httpBindAddr = `${DEFAULT_HOST}:${port}`;
837
+ const nextConfig = upsertServerConfig(existingConfig || "", httpBindAddr);
838
+ if (!existingConfig || existingConfig !== nextConfig) {
839
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
840
+ fs.writeFileSync(configPath, nextConfig);
841
+ }
842
+
843
+ const url = configUrlForPort(port);
844
+ const paths = clientConfigPaths();
845
+ upsertMcpServerJson(paths.claude, url);
846
+ upsertMcpServerJson(paths.cursor, url);
847
+ upsertCodexConfig(paths.codex, url);
848
+
849
+ const daemonRoot = ensureDaemonRoot();
850
+ const resolvedBinary = resolveBinaryPath({ binaryPath });
851
+ const startup = registerStartup({ binaryPath: resolvedBinary, port, repoRoot: daemonRoot, logger: log });
852
+ if (!startup.ok) {
853
+ if (!startupFailureReported()) {
854
+ log.warn?.("[docdex] startup registration failed; run the daemon manually:");
855
+ log.warn?.(`[docdex] ${resolvedBinary || "docdexd"} daemon --repo ${daemonRoot} --host ${DEFAULT_HOST} --port ${port}`);
856
+ recordStartupFailure({ reason: startup.reason, port, repoRoot: daemonRoot });
857
+ }
858
+ } else {
859
+ clearStartupFailure();
860
+ }
861
+
862
+ startDaemonNow({ binaryPath: resolvedBinary, port, repoRoot: daemonRoot });
863
+ await maybeInstallOllama({ logger: log });
864
+ await maybePromptOllamaModel({ logger: log, configPath });
865
+ return { port, url, configPath };
866
+ }
867
+
868
+ module.exports = {
869
+ runPostInstallSetup,
870
+ upsertServerConfig,
871
+ parseServerBind,
872
+ upsertMcpServerJson,
873
+ upsertCodexConfig,
874
+ pickAvailablePort,
875
+ configUrlForPort,
876
+ parseEnvBool,
877
+ resolveOllamaInstallMode,
878
+ resolveOllamaModelPromptMode,
879
+ parseOllamaListOutput,
880
+ formatGiB,
881
+ readLlmDefaultModel,
882
+ upsertLlmDefaultModel,
883
+ pullOllamaModel,
884
+ listOllamaModels
885
+ };