docdex 0.2.3 → 0.2.5

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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.3
3
+ ## 0.2.5
4
4
  - Added glama support
5
5
 
6
6
  ## 0.1.10
package/README.md CHANGED
@@ -12,6 +12,7 @@ Docdex is a local-first docs + code indexer/search daemon. It runs per repo, kee
12
12
  - If you publish from a fork, set `DOCDEX_DOWNLOAD_REPO=<owner/repo>` before installing so the downloader fetches your release assets.
13
13
  - If you mirror release assets locally, set `DOCDEX_DOWNLOAD_BASE=http://host/path` to point the installer at the mirror.
14
14
  - Distribution: binaries stay in GitHub Releases (small npm package); postinstall fetches `docdexd-<platform>.tar.gz` matching the npm version.
15
+ - Local dev install: if the release manifest/checksums are missing and a local `target/release/docdexd` (or `target/debug/docdexd`) exists, the installer falls back to that binary. Set `DOCDEX_LOCAL_FALLBACK=0` to disable or `DOCDEX_LOCAL_BINARY=/path/to/docdexd` to override.
15
16
  - Platform diagnostics (no download): `docdex doctor` (or `docdex diagnostics`) prints detected OS/arch(/libc), whether supported, and the expected Rust target triple + release asset naming pattern.
16
17
  - Publishing uses npm Trusted Publishing (OIDC) — no NPM token needed; see `.github/workflows/release.yml`.
17
18
  - Postinstall prompts: if Ollama is missing, the installer asks to install Ollama and `nomic-embed-text`. If Ollama is available, it prompts to pick a default chat model and can install `phi3.5:3.8b` (~2.2 GB) while showing free disk space. Skip with `DOCDEX_OLLAMA_INSTALL=0` or `DOCDEX_OLLAMA_MODEL_PROMPT=0`; force with `DOCDEX_OLLAMA_INSTALL=1` or `DOCDEX_OLLAMA_MODEL=<model>`; preselect with `DOCDEX_OLLAMA_DEFAULT_MODEL`.
package/lib/install.js CHANGED
@@ -37,6 +37,8 @@ const DEFAULT_INTEGRITY_CONFIG = Object.freeze({
37
37
  metadataSources: ["manifest", "checksums", "sidecar"],
38
38
  missingPolicy: "fallback"
39
39
  });
40
+ const LOCAL_FALLBACK_ENV = "DOCDEX_LOCAL_FALLBACK";
41
+ const LOCAL_BINARY_ENV = "DOCDEX_LOCAL_BINARY";
40
42
 
41
43
  const EXIT_CODE_BY_ERROR_CODE = Object.freeze({
42
44
  DOCDEX_INSTALLER_CONFIG: 2,
@@ -361,6 +363,141 @@ function normalizeSha256Hex(value) {
361
363
  return trimmed;
362
364
  }
363
365
 
366
+ function parseEnvBool(value) {
367
+ if (value == null) return null;
368
+ const normalized = String(value).trim().toLowerCase();
369
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
370
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
371
+ return null;
372
+ }
373
+
374
+ function detectLocalRepoRoot({ pathModule, fsModule } = {}) {
375
+ const pathImpl = pathModule || path;
376
+ const fsImpl = fsModule || fs;
377
+ const candidate = pathImpl.resolve(__dirname, "..", "..");
378
+ const hasCargo = fsImpl.existsSync(pathImpl.join(candidate, "Cargo.toml"));
379
+ const hasGit = fsImpl.existsSync(pathImpl.join(candidate, ".git"));
380
+ if (hasCargo || hasGit) {
381
+ return candidate;
382
+ }
383
+ return null;
384
+ }
385
+
386
+ function resolveLocalBinaryCandidate({
387
+ env = process.env,
388
+ platform = process.platform,
389
+ pathModule = path,
390
+ fsModule = fs,
391
+ repoRoot
392
+ } = {}) {
393
+ const explicit = env[LOCAL_BINARY_ENV];
394
+ if (explicit) {
395
+ const resolved = pathModule.resolve(explicit);
396
+ if (fsModule.existsSync(resolved)) return resolved;
397
+ }
398
+ const root = repoRoot || detectLocalRepoRoot({ pathModule, fsModule });
399
+ if (!root) return null;
400
+ const binaryName = platform === "win32" ? "docdexd.exe" : "docdexd";
401
+ const releasePath = pathModule.join(root, "target", "release", binaryName);
402
+ if (fsModule.existsSync(releasePath)) return releasePath;
403
+ const debugPath = pathModule.join(root, "target", "debug", binaryName);
404
+ if (fsModule.existsSync(debugPath)) return debugPath;
405
+ return null;
406
+ }
407
+
408
+ async function installFromLocalBinary({
409
+ fsModule,
410
+ pathModule,
411
+ distDir,
412
+ binaryPath,
413
+ isWin32,
414
+ version,
415
+ platformKey,
416
+ targetTriple,
417
+ repoSlug,
418
+ sha256FileFn,
419
+ writeJsonFileAtomicFn,
420
+ logger
421
+ }) {
422
+ await fsModule.promises.rm(distDir, { recursive: true, force: true });
423
+ await fsModule.promises.mkdir(distDir, { recursive: true });
424
+ const filename = isWin32 ? "docdexd.exe" : "docdexd";
425
+ const destPath = pathModule.join(distDir, filename);
426
+ await fsModule.promises.copyFile(binaryPath, destPath);
427
+ if (!isWin32) {
428
+ await fsModule.promises.chmod(destPath, 0o755).catch(() => {});
429
+ }
430
+ const binarySha256 = await sha256FileFn(destPath);
431
+ const metadata = {
432
+ schemaVersion: INSTALL_METADATA_SCHEMA_VERSION,
433
+ installedAt: nowIso(),
434
+ version,
435
+ repoSlug: repoSlug || "local",
436
+ platformKey,
437
+ targetTriple,
438
+ binary: {
439
+ filename,
440
+ sha256: binarySha256
441
+ },
442
+ archive: {
443
+ name: null,
444
+ sha256: null,
445
+ source: "local",
446
+ downloadUrl: null
447
+ }
448
+ };
449
+ await writeJsonFileAtomicFn({
450
+ fsModule,
451
+ pathModule,
452
+ filePath: installMetadataPath(distDir, pathModule),
453
+ value: metadata
454
+ });
455
+ logger?.warn?.(`[docdex] Installed local binary from ${binaryPath}`);
456
+ return { binaryPath: destPath, outcome: "local", outcomeCode: "local" };
457
+ }
458
+
459
+ async function maybeInstallLocalFallback({
460
+ err,
461
+ env,
462
+ fsModule,
463
+ pathModule,
464
+ distDir,
465
+ isWin32,
466
+ version,
467
+ platformKey,
468
+ targetTriple,
469
+ repoSlug,
470
+ sha256FileFn,
471
+ writeJsonFileAtomicFn,
472
+ logger,
473
+ localRepoRoot,
474
+ localBinaryPath
475
+ }) {
476
+ if (!err || err.code !== "DOCDEX_CHECKSUM_UNUSABLE") return null;
477
+ const allowFallback = parseEnvBool(env[LOCAL_FALLBACK_ENV]);
478
+ if (allowFallback === false) return null;
479
+
480
+ const candidate =
481
+ localBinaryPath ||
482
+ resolveLocalBinaryCandidate({ env, platform: process.platform, pathModule, fsModule, repoRoot: localRepoRoot });
483
+ if (!candidate) return null;
484
+
485
+ return installFromLocalBinary({
486
+ fsModule,
487
+ pathModule,
488
+ distDir,
489
+ binaryPath: candidate,
490
+ isWin32,
491
+ version,
492
+ platformKey,
493
+ targetTriple,
494
+ repoSlug,
495
+ sha256FileFn,
496
+ writeJsonFileAtomicFn,
497
+ logger
498
+ });
499
+ }
500
+
364
501
  function emitInstallerEvent(logger, payload) {
365
502
  if (!logger || typeof logger.log !== "function") return;
366
503
  try {
@@ -1348,6 +1485,8 @@ async function runInstaller(options) {
1348
1485
  const sha256FileFn = opts.sha256FileFn || sha256File;
1349
1486
  const writeJsonFileAtomicFn = opts.writeJsonFileAtomicFn || writeJsonFileAtomic;
1350
1487
  const restartFn = opts.restartFn;
1488
+ const localRepoRoot = opts.localRepoRoot;
1489
+ const localBinaryPath = opts.localBinaryPath;
1351
1490
 
1352
1491
  const detectedPlatform = opts.platform || process.platform;
1353
1492
  const detectedArch = opts.arch || process.arch;
@@ -1431,16 +1570,48 @@ async function runInstaller(options) {
1431
1570
  };
1432
1571
  }
1433
1572
 
1434
- const repoSlug = parseRepoSlugFn();
1435
-
1436
- const { archive, expectedSha256, source, manifestAttempt } = await resolveInstallerDownloadPlanFn({
1437
- repoSlug,
1438
- version,
1439
- platformKey,
1440
- targetTriple,
1441
- logger,
1442
- integrityConfigFn: opts.integrityConfigFn
1443
- });
1573
+ let repoSlug = null;
1574
+ let archive;
1575
+ let expectedSha256;
1576
+ let source;
1577
+ let manifestAttempt;
1578
+ try {
1579
+ repoSlug = parseRepoSlugFn();
1580
+ const resolved = await resolveInstallerDownloadPlanFn({
1581
+ repoSlug,
1582
+ version,
1583
+ platformKey,
1584
+ targetTriple,
1585
+ logger,
1586
+ integrityConfigFn: opts.integrityConfigFn
1587
+ });
1588
+ archive = resolved.archive;
1589
+ expectedSha256 = resolved.expectedSha256;
1590
+ source = resolved.source;
1591
+ manifestAttempt = resolved.manifestAttempt;
1592
+ } catch (err) {
1593
+ const fallback = await maybeInstallLocalFallback({
1594
+ err,
1595
+ env: process.env,
1596
+ fsModule,
1597
+ pathModule,
1598
+ distDir,
1599
+ isWin32,
1600
+ version,
1601
+ platformKey,
1602
+ targetTriple,
1603
+ repoSlug,
1604
+ sha256FileFn,
1605
+ writeJsonFileAtomicFn,
1606
+ logger,
1607
+ localRepoRoot,
1608
+ localBinaryPath
1609
+ });
1610
+ if (fallback) {
1611
+ return fallback;
1612
+ }
1613
+ throw err;
1614
+ }
1444
1615
 
1445
1616
  const downloadUrl = `${getDownloadBaseFn(repoSlug)}/v${version}/${archive}`;
1446
1617
  const nonce = buildInstallNonce();
@@ -158,22 +158,166 @@ function upsertMcpServerJson(pathname, url) {
158
158
  }
159
159
 
160
160
  function upsertCodexConfig(pathname, url) {
161
+ const hasSection = (contents, section) =>
162
+ new RegExp(`^\\s*\\[${section}\\]\\s*$`, "m").test(contents);
163
+ const hasNestedMcpServers = (contents) =>
164
+ /^\s*\[mcp_servers\.[^\]]+\]\s*$/m.test(contents);
165
+ const parseTomlString = (value) => {
166
+ const trimmed = value.trim();
167
+ const quoted = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
168
+ return quoted ? quoted[1] : trimmed;
169
+ };
170
+ const migrateLegacyMcpServers = (contents) => {
171
+ if (!/\[\[mcp_servers\]\]/m.test(contents)) {
172
+ return { contents, migrated: false };
173
+ }
174
+ const lines = contents.split(/\r?\n/);
175
+ const output = [];
176
+ const entries = [];
177
+ let inBlock = false;
178
+ let current = null;
179
+
180
+ for (const line of lines) {
181
+ if (/^\s*\[\[mcp_servers\]\]\s*$/.test(line)) {
182
+ if (current) entries.push(current);
183
+ current = {};
184
+ inBlock = true;
185
+ continue;
186
+ }
187
+ if (inBlock) {
188
+ if (/^\s*\[.+\]\s*$/.test(line)) {
189
+ if (current) entries.push(current);
190
+ current = null;
191
+ inBlock = false;
192
+ output.push(line);
193
+ continue;
194
+ }
195
+ const match = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*(.+?)\s*$/);
196
+ if (match) {
197
+ current[match[1]] = match[2].trim();
198
+ }
199
+ continue;
200
+ }
201
+ output.push(line);
202
+ }
203
+ if (current) entries.push(current);
204
+
205
+ const mapLines = [];
206
+ for (const entry of entries) {
207
+ if (!entry.name) continue;
208
+ const name = parseTomlString(entry.name);
209
+ if (!name) continue;
210
+ mapLines.push(`[mcp_servers.${name}]`);
211
+ for (const [key, value] of Object.entries(entry)) {
212
+ if (key === "name") continue;
213
+ mapLines.push(`${key} = ${value}`);
214
+ }
215
+ mapLines.push("");
216
+ }
217
+
218
+ if (mapLines.length === 0) {
219
+ return { contents: output.join("\n"), migrated: true };
220
+ }
221
+ if (output.length && output[output.length - 1].trim()) output.push("");
222
+ while (mapLines.length && !mapLines[mapLines.length - 1].trim()) mapLines.pop();
223
+ output.push(...mapLines);
224
+ return { contents: output.join("\n"), migrated: true };
225
+ };
226
+
227
+ const upsertDocdexNested = (contents, urlValue) => {
228
+ const lines = contents.split(/\r?\n/);
229
+ const headerRe = /^\s*\[mcp_servers\.docdex\]\s*$/;
230
+ let start = lines.findIndex((line) => headerRe.test(line));
231
+ if (start === -1) {
232
+ if (lines.length && lines[lines.length - 1].trim()) lines.push("");
233
+ lines.push("[mcp_servers.docdex]");
234
+ lines.push(`url = "${urlValue}"`);
235
+ return { contents: lines.join("\n"), updated: true };
236
+ }
237
+ let end = start + 1;
238
+ while (end < lines.length && !/^\s*\[.+\]\s*$/.test(lines[end])) {
239
+ end += 1;
240
+ }
241
+ let updated = false;
242
+ let urlIndex = -1;
243
+ for (let i = start + 1; i < end; i += 1) {
244
+ if (/^\s*url\s*=/.test(lines[i])) {
245
+ urlIndex = i;
246
+ break;
247
+ }
248
+ }
249
+ if (urlIndex === -1) {
250
+ lines.splice(start + 1, 0, `url = "${urlValue}"`);
251
+ updated = true;
252
+ } else if (!lines[urlIndex].includes(`"${urlValue}"`)) {
253
+ lines[urlIndex] = `url = "${urlValue}"`;
254
+ updated = true;
255
+ }
256
+ return { contents: lines.join("\n"), updated };
257
+ };
258
+
259
+ const upsertDocdexRoot = (contents, urlValue) => {
260
+ const lines = contents.split(/\r?\n/);
261
+ const headerRe = /^\s*\[mcp_servers\]\s*$/;
262
+ const start = lines.findIndex((line) => headerRe.test(line));
263
+ if (start === -1) {
264
+ if (lines.length && lines[lines.length - 1].trim()) lines.push("");
265
+ lines.push("[mcp_servers]");
266
+ lines.push(`docdex = { url = "${urlValue}" }`);
267
+ return { contents: lines.join("\n"), updated: true };
268
+ }
269
+ let end = start + 1;
270
+ while (end < lines.length && !/^\s*\[.+\]\s*$/.test(lines[end])) {
271
+ end += 1;
272
+ }
273
+ let updated = false;
274
+ let docdexLine = -1;
275
+ for (let i = start + 1; i < end; i += 1) {
276
+ if (/^\s*docdex\s*=/.test(lines[i])) {
277
+ docdexLine = i;
278
+ break;
279
+ }
280
+ }
281
+ if (docdexLine === -1) {
282
+ lines.splice(end, 0, `docdex = { url = "${urlValue}" }`);
283
+ updated = true;
284
+ } else if (!lines[docdexLine].includes(`"${urlValue}"`)) {
285
+ lines[docdexLine] = `docdex = { url = "${urlValue}" }`;
286
+ updated = true;
287
+ }
288
+ return { contents: lines.join("\n"), updated };
289
+ };
290
+
161
291
  let contents = "";
162
292
  if (fs.existsSync(pathname)) {
163
293
  contents = fs.readFileSync(pathname, "utf8");
164
294
  }
165
- if (/name\s*=\s*\"docdex\"/.test(contents) || /docdex/.test(contents) && /mcp_servers/.test(contents)) {
295
+ let updated = false;
296
+ if (/\[\[mcp_servers\]\]/m.test(contents)) {
297
+ const migrated = migrateLegacyMcpServers(contents);
298
+ contents = migrated.contents;
299
+ updated = updated || migrated.migrated;
300
+ }
301
+
302
+ if (hasNestedMcpServers(contents)) {
303
+ const nested = upsertDocdexNested(contents, url);
304
+ contents = nested.contents;
305
+ updated = updated || nested.updated;
306
+ } else if (hasSection(contents, "mcp_servers")) {
307
+ const root = upsertDocdexRoot(contents, url);
308
+ contents = root.contents;
309
+ updated = updated || root.updated;
310
+ } else {
311
+ const root = upsertDocdexRoot(contents, url);
312
+ contents = root.contents;
313
+ updated = updated || root.updated;
314
+ }
315
+
316
+ if (!updated) {
166
317
  return false;
167
318
  }
168
- const block = [
169
- "",
170
- "[[mcp_servers]]",
171
- 'name = "docdex"',
172
- `url = "${url}"`,
173
- "",
174
- ].join("\n");
175
319
  fs.mkdirSync(path.dirname(pathname), { recursive: true });
176
- fs.writeFileSync(pathname, contents + block);
320
+ fs.writeFileSync(pathname, contents.endsWith("\n") ? contents : `${contents}\n`);
177
321
  return true;
178
322
  }
179
323
 
@@ -233,24 +377,52 @@ function parseEnvBool(value) {
233
377
  return null;
234
378
  }
235
379
 
236
- function resolveOllamaInstallMode({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
380
+ function hasInteractiveTty(stdin, stdout) {
381
+ return Boolean((stdin && stdin.isTTY) || (stdout && stdout.isTTY));
382
+ }
383
+
384
+ function canPromptWithTty(stdin, stdout) {
385
+ if (hasInteractiveTty(stdin, stdout)) return true;
386
+ const ttyPath = process.platform === "win32" ? "CONIN$" : "/dev/tty";
387
+ try {
388
+ const fd = fs.openSync(ttyPath, "r");
389
+ fs.closeSync(fd);
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ function resolveOllamaInstallMode({
397
+ env = process.env,
398
+ stdin = process.stdin,
399
+ stdout = process.stdout,
400
+ canPrompt = canPromptWithTty
401
+ } = {}) {
237
402
  const override = parseEnvBool(env.DOCDEX_OLLAMA_INSTALL);
238
403
  if (override === true) return { mode: "install", reason: "env", interactive: false };
239
404
  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 };
405
+ if (!canPrompt(stdin, stdout)) {
406
+ return { mode: "skip", reason: "non_interactive", interactive: false };
407
+ }
242
408
  if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
243
409
  return { mode: "prompt", reason: "interactive", interactive: true };
244
410
  }
245
411
 
246
- function resolveOllamaModelPromptMode({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
412
+ function resolveOllamaModelPromptMode({
413
+ env = process.env,
414
+ stdin = process.stdin,
415
+ stdout = process.stdout,
416
+ canPrompt = canPromptWithTty
417
+ } = {}) {
247
418
  const override = parseEnvBool(env.DOCDEX_OLLAMA_MODEL_PROMPT);
248
419
  if (override === true) return { mode: "prompt", reason: "env", interactive: true };
249
420
  if (override === false) return { mode: "skip", reason: "env", interactive: false };
250
421
  const assumeYes = parseEnvBool(env.DOCDEX_OLLAMA_MODEL_ASSUME_Y);
251
422
  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 };
423
+ if (!canPrompt(stdin, stdout)) {
424
+ return { mode: "skip", reason: "non_interactive", interactive: false };
425
+ }
254
426
  if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
255
427
  return { mode: "prompt", reason: "interactive", interactive: true };
256
428
  }
@@ -408,11 +580,27 @@ function isOllamaAvailable() {
408
580
  return isCommandAvailable("ollama", ["--version"]);
409
581
  }
410
582
 
583
+ function resolvePromptStreams(stdin, stdout) {
584
+ if (hasInteractiveTty(stdin, stdout)) {
585
+ return { input: stdin, output: stdout, close: null };
586
+ }
587
+ const isWindows = process.platform === "win32";
588
+ const ttyPath = isWindows ? "CONIN$" : "/dev/tty";
589
+ try {
590
+ const input = fs.createReadStream(ttyPath, { autoClose: true });
591
+ return { input, output: stdout, close: () => input.close() };
592
+ } catch {
593
+ return { input: stdin, output: stdout, close: null };
594
+ }
595
+ }
596
+
411
597
  function promptYesNo(question, { defaultYes = true, stdin = process.stdin, stdout = process.stdout } = {}) {
412
598
  return new Promise((resolve) => {
413
- const rl = readline.createInterface({ input: stdin, output: stdout });
599
+ const { input, output, close } = resolvePromptStreams(stdin, stdout);
600
+ const rl = readline.createInterface({ input, output });
414
601
  rl.question(question, (answer) => {
415
602
  rl.close();
603
+ if (typeof close === "function") close();
416
604
  const normalized = String(answer || "").trim().toLowerCase();
417
605
  if (!normalized) return resolve(defaultYes);
418
606
  resolve(["y", "yes"].includes(normalized));
@@ -422,9 +610,11 @@ function promptYesNo(question, { defaultYes = true, stdin = process.stdin, stdou
422
610
 
423
611
  function promptInput(question, { stdin = process.stdin, stdout = process.stdout } = {}) {
424
612
  return new Promise((resolve) => {
425
- const rl = readline.createInterface({ input: stdin, output: stdout });
613
+ const { input, output, close } = resolvePromptStreams(stdin, stdout);
614
+ const rl = readline.createInterface({ input, output });
426
615
  rl.question(question, (answer) => {
427
616
  rl.close();
617
+ if (typeof close === "function") close();
428
618
  resolve(String(answer || "").trim());
429
619
  });
430
620
  });
@@ -881,5 +1071,7 @@ module.exports = {
881
1071
  readLlmDefaultModel,
882
1072
  upsertLlmDefaultModel,
883
1073
  pullOllamaModel,
884
- listOllamaModels
1074
+ listOllamaModels,
1075
+ hasInteractiveTty,
1076
+ canPromptWithTty
885
1077
  };
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawnSync } = require("node:child_process");
8
+
9
+ const DAEMON_TASK_NAME = "Docdex Daemon";
10
+ const STARTUP_FAILURE_MARKER = "startup_registration_failed.json";
11
+
12
+ function daemonRootPath() {
13
+ return path.join(os.homedir(), ".docdex", "daemon_root");
14
+ }
15
+
16
+ function stateDir() {
17
+ return path.join(os.homedir(), ".docdex", "state");
18
+ }
19
+
20
+ function daemonLockPath() {
21
+ const override = process.env.DOCDEX_DAEMON_LOCK_PATH;
22
+ if (override && override.trim()) return override.trim();
23
+ return path.join(os.homedir(), ".docdex", "daemon.lock");
24
+ }
25
+
26
+ function clientConfigPaths() {
27
+ const home = os.homedir();
28
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
29
+ const userProfile = process.env.USERPROFILE || home;
30
+ switch (process.platform) {
31
+ case "win32":
32
+ return {
33
+ json: [
34
+ path.join(appData, "Claude", "claude_desktop_config.json"),
35
+ path.join(userProfile, ".cursor", "mcp.json"),
36
+ path.join(userProfile, ".cursor", "settings.json"),
37
+ path.join(userProfile, ".codeium", "windsurf", "mcp_config.json"),
38
+ path.join(appData, "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
39
+ path.join(appData, "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
40
+ path.join(userProfile, ".continue", "config.json"),
41
+ path.join(userProfile, ".kiro", "settings", "mcp.json"),
42
+ path.join(appData, "Zed", "settings.json")
43
+ ],
44
+ toml: [path.join(userProfile, ".codex", "config.toml")],
45
+ yaml: [path.join(appData, "Aider", "config.yml")]
46
+ };
47
+ case "darwin":
48
+ return {
49
+ json: [
50
+ path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
51
+ path.join(home, ".cursor", "mcp.json"),
52
+ path.join(home, ".cursor", "settings.json"),
53
+ path.join(home, ".codeium", "windsurf", "mcp_config.json"),
54
+ path.join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
55
+ path.join(home, "Library", "Application Support", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
56
+ path.join(home, ".continue", "config.json"),
57
+ path.join(home, ".kiro", "settings", "mcp.json"),
58
+ path.join(home, ".config", "zed", "settings.json")
59
+ ],
60
+ toml: [path.join(home, ".codex", "config.toml")],
61
+ yaml: [path.join(home, ".config", "aider", "config.yml"), path.join(home, ".aider.conf.yml")]
62
+ };
63
+ default:
64
+ return {
65
+ json: [
66
+ path.join(home, ".config", "Claude", "claude_desktop_config.json"),
67
+ path.join(home, ".cursor", "mcp.json"),
68
+ path.join(home, ".cursor", "settings.json"),
69
+ path.join(home, ".codeium", "windsurf", "mcp_config.json"),
70
+ path.join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
71
+ path.join(home, ".config", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
72
+ path.join(home, ".continue", "config.json"),
73
+ path.join(home, ".kiro", "settings", "mcp.json"),
74
+ path.join(home, ".config", "zed", "settings.json")
75
+ ],
76
+ toml: [path.join(home, ".codex", "config.toml")],
77
+ yaml: [path.join(home, ".config", "aider", "config.yml"), path.join(home, ".aider.conf.yml")]
78
+ };
79
+ }
80
+ }
81
+
82
+ function readJson(pathname) {
83
+ try {
84
+ if (!fs.existsSync(pathname)) return { value: {}, exists: false };
85
+ const raw = fs.readFileSync(pathname, "utf8");
86
+ if (!raw.trim()) return { value: {}, exists: true };
87
+ return { value: JSON.parse(raw), exists: true };
88
+ } catch {
89
+ return { value: {}, exists: true };
90
+ }
91
+ }
92
+
93
+ function writeJson(pathname, value) {
94
+ fs.mkdirSync(path.dirname(pathname), { recursive: true });
95
+ fs.writeFileSync(pathname, JSON.stringify(value, null, 2) + "\n");
96
+ }
97
+
98
+ function removeMcpServerJson(pathname, name = "docdex") {
99
+ const { value, exists } = readJson(pathname);
100
+ if (!exists || typeof value !== "object" || value == null || Array.isArray(value)) return false;
101
+ const root = value;
102
+ const keys = ["mcpServers", "mcp_servers"];
103
+ let changed = false;
104
+ for (const key of keys) {
105
+ const section = root[key];
106
+ if (!section || typeof section !== "object" || Array.isArray(section)) continue;
107
+ if (!Object.prototype.hasOwnProperty.call(section, name)) continue;
108
+ delete section[name];
109
+ changed = true;
110
+ if (Object.keys(section).length === 0) delete root[key];
111
+ }
112
+ if (!changed) return false;
113
+ writeJson(pathname, root);
114
+ return true;
115
+ }
116
+
117
+ function removeCodexConfig(pathname, name = "docdex") {
118
+ if (!fs.existsSync(pathname)) return false;
119
+ let contents = fs.readFileSync(pathname, "utf8");
120
+ const original = contents;
121
+
122
+ const parseTomlString = (value) => {
123
+ const trimmed = value.trim();
124
+ const quoted = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
125
+ return quoted ? quoted[1] : trimmed;
126
+ };
127
+
128
+ const removeArrayBlocks = (text) => {
129
+ const lines = text.split(/\r?\n/);
130
+ const output = [];
131
+ let inBlock = false;
132
+ let block = [];
133
+ let blockHasName = false;
134
+
135
+ const flush = () => {
136
+ if (!inBlock) return;
137
+ if (!blockHasName) output.push(...block);
138
+ inBlock = false;
139
+ block = [];
140
+ blockHasName = false;
141
+ };
142
+
143
+ for (const line of lines) {
144
+ if (/^\s*\[\[mcp_servers\]\]\s*$/.test(line)) {
145
+ flush();
146
+ inBlock = true;
147
+ block = [line];
148
+ continue;
149
+ }
150
+ if (inBlock) {
151
+ if (/^\s*\[.+\]\s*$/.test(line)) {
152
+ flush();
153
+ output.push(line);
154
+ continue;
155
+ }
156
+ const match = line.match(/^\s*name\s*=\s*(.+?)\s*$/);
157
+ if (match && parseTomlString(match[1]) === name) {
158
+ blockHasName = true;
159
+ }
160
+ block.push(line);
161
+ continue;
162
+ }
163
+ output.push(line);
164
+ }
165
+ flush();
166
+ return output.join("\n");
167
+ };
168
+
169
+ const removeNestedSection = (text) => {
170
+ const lines = text.split(/\r?\n/);
171
+ const output = [];
172
+ let skip = false;
173
+ for (const line of lines) {
174
+ if (/^\s*\[mcp_servers\.docdex\]\s*$/.test(line)) {
175
+ skip = true;
176
+ continue;
177
+ }
178
+ if (skip) {
179
+ if (/^\s*\[.+\]\s*$/.test(line)) {
180
+ skip = false;
181
+ output.push(line);
182
+ }
183
+ continue;
184
+ }
185
+ output.push(line);
186
+ }
187
+ return output.join("\n");
188
+ };
189
+
190
+ const removeTableEntry = (text) => {
191
+ const lines = text.split(/\r?\n/);
192
+ const output = [];
193
+ let inTable = false;
194
+ for (const line of lines) {
195
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
196
+ if (section) {
197
+ inTable = section[1].trim() === "mcp_servers";
198
+ output.push(line);
199
+ continue;
200
+ }
201
+ if (inTable && new RegExp(`^\\s*${name}\\s*=`).test(line)) {
202
+ continue;
203
+ }
204
+ output.push(line);
205
+ }
206
+ return output.join("\n");
207
+ };
208
+
209
+ contents = removeArrayBlocks(contents);
210
+ contents = removeNestedSection(contents);
211
+ contents = removeTableEntry(contents);
212
+
213
+ if (contents !== original) {
214
+ fs.writeFileSync(pathname, contents.endsWith("\n") ? contents : `${contents}\n`);
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ function removeMcpServerYaml(pathname, name = "docdex") {
221
+ if (!fs.existsSync(pathname)) return false;
222
+ const original = fs.readFileSync(pathname, "utf8");
223
+ const lines = original.split(/\r?\n/);
224
+ const output = [];
225
+ let inSection = false;
226
+ let sectionIndent = null;
227
+ let skipIndent = null;
228
+ let changed = false;
229
+
230
+ const indentSize = (line) => (line.match(/^\s*/)?.[0].length ?? 0);
231
+
232
+ for (const line of lines) {
233
+ if (skipIndent != null) {
234
+ if (line.trim() && indentSize(line) <= skipIndent) {
235
+ skipIndent = null;
236
+ } else {
237
+ changed = true;
238
+ continue;
239
+ }
240
+ }
241
+
242
+ if (!inSection) {
243
+ if (/^\s*mcp_servers\s*:\s*$/.test(line)) {
244
+ inSection = true;
245
+ sectionIndent = indentSize(line);
246
+ }
247
+ output.push(line);
248
+ continue;
249
+ }
250
+
251
+ if (line.trim() && indentSize(line) <= sectionIndent) {
252
+ inSection = false;
253
+ output.push(line);
254
+ continue;
255
+ }
256
+
257
+ if (new RegExp(`^\\s*${name}\\s*:`).test(line)) {
258
+ changed = true;
259
+ skipIndent = indentSize(line);
260
+ continue;
261
+ }
262
+
263
+ const listName = line.match(/^\s*-\s*name\s*:\s*(.+)\s*$/);
264
+ if (listName && listName[1].replace(/["']/g, "").trim() === name) {
265
+ changed = true;
266
+ skipIndent = indentSize(line);
267
+ continue;
268
+ }
269
+
270
+ const listValue = line.match(/^\s*-\s*([^\s#]+)\s*$/);
271
+ if (listValue && listValue[1].replace(/["']/g, "").trim() === name) {
272
+ changed = true;
273
+ continue;
274
+ }
275
+
276
+ output.push(line);
277
+ }
278
+
279
+ if (changed) {
280
+ fs.writeFileSync(pathname, output.join("\n"));
281
+ }
282
+ return changed;
283
+ }
284
+
285
+ function killPid(pid) {
286
+ if (!pid) return false;
287
+ try {
288
+ if (process.platform === "win32") {
289
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"]);
290
+ return true;
291
+ }
292
+ process.kill(pid, "SIGTERM");
293
+ return true;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
299
+ function stopDaemonFromLock() {
300
+ const lockPath = daemonLockPath();
301
+ if (!fs.existsSync(lockPath)) return false;
302
+ try {
303
+ const raw = fs.readFileSync(lockPath, "utf8");
304
+ const payload = JSON.parse(raw);
305
+ const pid = payload && typeof payload.pid === "number" ? payload.pid : null;
306
+ const stopped = killPid(pid);
307
+ fs.unlinkSync(lockPath);
308
+ return stopped;
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ function stopDaemonByName() {
315
+ if (process.platform === "win32") {
316
+ spawnSync("taskkill", ["/IM", "docdexd.exe", "/T", "/F"]);
317
+ return true;
318
+ }
319
+ spawnSync("pkill", ["-TERM", "-x", "docdexd"]);
320
+ spawnSync("pkill", ["-TERM", "-f", "docdexd daemon"]);
321
+ return true;
322
+ }
323
+
324
+ function unregisterStartup() {
325
+ if (process.platform === "darwin") {
326
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.docdex.daemon.plist");
327
+ if (fs.existsSync(plistPath)) {
328
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
329
+ if (uid != null) {
330
+ spawnSync("launchctl", ["bootout", `gui/${uid}`, plistPath]);
331
+ }
332
+ spawnSync("launchctl", ["unload", "-w", plistPath]);
333
+ spawnSync("launchctl", ["remove", "com.docdex.daemon"]);
334
+ try {
335
+ fs.unlinkSync(plistPath);
336
+ } catch {}
337
+ }
338
+ return true;
339
+ }
340
+
341
+ if (process.platform === "linux") {
342
+ const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
343
+ const unitPath = path.join(systemdDir, "docdexd.service");
344
+ spawnSync("systemctl", ["--user", "disable", "--now", "docdexd.service"]);
345
+ if (fs.existsSync(unitPath)) {
346
+ try {
347
+ fs.unlinkSync(unitPath);
348
+ } catch {}
349
+ spawnSync("systemctl", ["--user", "daemon-reload"]);
350
+ }
351
+ return true;
352
+ }
353
+
354
+ if (process.platform === "win32") {
355
+ spawnSync("schtasks", ["/End", "/TN", DAEMON_TASK_NAME]);
356
+ spawnSync("schtasks", ["/Delete", "/TN", DAEMON_TASK_NAME, "/F"]);
357
+ return true;
358
+ }
359
+
360
+ return false;
361
+ }
362
+
363
+ function clearStartupFailure() {
364
+ const markerPath = path.join(stateDir(), STARTUP_FAILURE_MARKER);
365
+ if (fs.existsSync(markerPath)) {
366
+ try {
367
+ fs.unlinkSync(markerPath);
368
+ } catch {}
369
+ }
370
+ }
371
+
372
+ function removeDaemonRootNotice() {
373
+ const root = daemonRootPath();
374
+ const readmes = [path.join(root, "README.txt"), path.join(root, "README.md")];
375
+ for (const readme of readmes) {
376
+ if (fs.existsSync(readme)) {
377
+ try {
378
+ fs.unlinkSync(readme);
379
+ } catch {}
380
+ }
381
+ }
382
+ }
383
+
384
+ function removeClientConfigs() {
385
+ const paths = clientConfigPaths();
386
+ for (const pathname of paths.json || []) {
387
+ removeMcpServerJson(pathname);
388
+ }
389
+ for (const pathname of paths.toml || []) {
390
+ removeCodexConfig(pathname);
391
+ }
392
+ for (const pathname of paths.yaml || []) {
393
+ removeMcpServerYaml(pathname);
394
+ }
395
+ }
396
+
397
+ async function main() {
398
+ const stopped = stopDaemonFromLock();
399
+ if (!stopped) {
400
+ stopDaemonByName();
401
+ }
402
+ unregisterStartup();
403
+ removeClientConfigs();
404
+ clearStartupFailure();
405
+ removeDaemonRootNotice();
406
+ }
407
+
408
+ if (require.main === module) {
409
+ main().catch(() => process.exit(0));
410
+ }
411
+
412
+ module.exports = {
413
+ removeMcpServerJson,
414
+ removeCodexConfig,
415
+ removeMcpServerYaml,
416
+ stopDaemonFromLock,
417
+ stopDaemonByName,
418
+ unregisterStartup,
419
+ removeClientConfigs
420
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docdex",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Docdex CLI as an npm-installable binary wrapper.",
5
5
  "bin": {
6
6
  "docdex": "bin/docdex.js",
@@ -15,6 +15,7 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "postinstall": "node ./lib/install.js",
18
+ "postuninstall": "node ./lib/uninstall.js",
18
19
  "test": "node --test",
19
20
  "pack:verify": "node --test test/packaging_guardrails.test.js"
20
21
  },