docdex 0.2.18 → 0.2.20

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,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.20
4
+ - Prompt for npm updates at CLI start (TTY-only, opt-out via `DOCDEX_UPDATE_CHECK=0`).
5
+ - Export bundled Playwright fetcher for daemon startup (launchd/systemd/schtasks + immediate spawn).
6
+ - Pass `DOCDEX_PLAYWRIGHT_FETCHER` in the npm wrapper when launching the daemon.
7
+
8
+ ## 0.2.19
9
+ - Playwright issue fix
10
+ - Agents md adding command manually
11
+ - Agents md append repeat fix
12
+
3
13
  ## 0.2.16
4
14
  - Repo memory now tags items with `repoId` and filters recalls to prevent cross-repo leakage in multi-repo daemons.
5
15
  - MCP HTTP requires explicit repo selection when multiple repos are active.
package/bin/docdex.js CHANGED
@@ -5,6 +5,7 @@ const fs = require("node:fs");
5
5
  const path = require("node:path");
6
6
  const { spawn } = require("node:child_process");
7
7
 
8
+ const pkg = require("../package.json");
8
9
  const {
9
10
  artifactName,
10
11
  detectLibcFromRuntime,
@@ -13,6 +14,7 @@ const {
13
14
  assetPatternForPlatformKey,
14
15
  UnsupportedPlatformError
15
16
  } = require("../lib/platform");
17
+ const { checkForUpdateOnce } = require("../lib/update_check");
16
18
 
17
19
  function isDoctorCommand(argv) {
18
20
  const sub = argv[0];
@@ -139,7 +141,7 @@ function runDoctor() {
139
141
  process.exit(report.exitCode);
140
142
  }
141
143
 
142
- function run() {
144
+ async function run() {
143
145
  const argv = process.argv.slice(2);
144
146
  if (isDoctorCommand(argv)) {
145
147
  runDoctor();
@@ -164,9 +166,11 @@ function run() {
164
166
  }
165
167
  console.error("[docdex] Next steps: use a supported platform or build from source (Rust).");
166
168
  process.exit(err.exitCode || 3);
169
+ return;
167
170
  }
168
171
  console.error(`[docdex] failed to detect platform: ${err?.message || String(err)}`);
169
172
  process.exit(1);
173
+ return;
170
174
  }
171
175
 
172
176
  const basePath = path.join(__dirname, "..", "dist", platformKey);
@@ -186,12 +190,25 @@ function run() {
186
190
  console.error(`[docdex] Asset naming pattern: ${assetPatternForPlatformKey(platformKey)}`);
187
191
  } catch {}
188
192
  process.exit(1);
193
+ return;
189
194
  }
190
195
 
196
+ await checkForUpdateOnce({
197
+ currentVersion: pkg.version,
198
+ env: process.env,
199
+ stdout: process.stdout,
200
+ stderr: process.stderr,
201
+ logger: console
202
+ });
203
+
191
204
  const env = { ...process.env };
192
205
  if (!env.DOCDEX_MCP_SERVER_BIN && fs.existsSync(mcpBinaryPath)) {
193
206
  env.DOCDEX_MCP_SERVER_BIN = mcpBinaryPath;
194
207
  }
208
+ const fetcherPath = path.join(__dirname, "..", "lib", "playwright_fetch.js");
209
+ if (!env.DOCDEX_PLAYWRIGHT_FETCHER && fs.existsSync(fetcherPath)) {
210
+ env.DOCDEX_PLAYWRIGHT_FETCHER = fetcherPath;
211
+ }
195
212
  const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", env });
196
213
  child.on("exit", (code) => process.exit(code ?? 1));
197
214
  child.on("error", (err) => {
@@ -200,4 +217,7 @@ function run() {
200
217
  });
201
218
  }
202
219
 
203
- run();
220
+ run().catch((err) => {
221
+ console.error(`[docdex] unexpected error: ${err?.message || String(err)}`);
222
+ process.exit(1);
223
+ });
@@ -20,6 +20,8 @@ const DEFAULT_OLLAMA_CHAT_MODEL = "phi3.5:3.8b";
20
20
  const DEFAULT_OLLAMA_CHAT_MODEL_SIZE_GIB = 2.2;
21
21
  const SETUP_PENDING_MARKER = "setup_pending.json";
22
22
  const AGENTS_DOC_FILENAME = "agents.md";
23
+ const DOCDEX_INFO_START_PREFIX = "---- START OF DOCDEX INFO V";
24
+ const DOCDEX_INFO_END = "---- END OF DOCDEX INFO -----";
23
25
 
24
26
  function defaultConfigPath() {
25
27
  return path.join(os.homedir(), ".docdex", "config.toml");
@@ -158,6 +160,18 @@ function agentsDocSourcePath() {
158
160
  return path.join(__dirname, "..", "assets", AGENTS_DOC_FILENAME);
159
161
  }
160
162
 
163
+ function resolvePackageVersion() {
164
+ const packagePath = path.join(__dirname, "..", "package.json");
165
+ if (!fs.existsSync(packagePath)) return "unknown";
166
+ try {
167
+ const raw = fs.readFileSync(packagePath, "utf8");
168
+ const parsed = JSON.parse(raw);
169
+ return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() : "unknown";
170
+ } catch {
171
+ return "unknown";
172
+ }
173
+ }
174
+
161
175
  function loadAgentInstructions() {
162
176
  const sourcePath = agentsDocSourcePath();
163
177
  if (!fs.existsSync(sourcePath)) return "";
@@ -172,13 +186,123 @@ function normalizeInstructionText(value) {
172
186
  return String(value || "").trim();
173
187
  }
174
188
 
175
- function mergeInstructionText(existing, instructions) {
189
+ function docdexBlockStart(version) {
190
+ return `${DOCDEX_INFO_START_PREFIX}${version} ----`;
191
+ }
192
+
193
+ function buildDocdexInstructionBlock(instructions) {
194
+ const next = normalizeInstructionText(instructions);
195
+ if (!next) return "";
196
+ const version = resolvePackageVersion();
197
+ return `${docdexBlockStart(version)}\n${next}\n${DOCDEX_INFO_END}`;
198
+ }
199
+
200
+ function extractDocdexBlockBody(text) {
201
+ const match = String(text || "").match(
202
+ new RegExp(
203
+ `${escapeRegExp(DOCDEX_INFO_START_PREFIX)}[^\\r\\n]* ----\\r?\\n([\\s\\S]*?)\\r?\\n${escapeRegExp(
204
+ DOCDEX_INFO_END
205
+ )}`
206
+ )
207
+ );
208
+ return match ? normalizeInstructionText(match[1]) : "";
209
+ }
210
+
211
+ function extractDocdexBlockVersion(text) {
212
+ const match = String(text || "").match(
213
+ new RegExp(`${escapeRegExp(DOCDEX_INFO_START_PREFIX)}([^\\s]+) ----`)
214
+ );
215
+ return match ? match[1] : null;
216
+ }
217
+
218
+ function hasDocdexBlockVersion(text, version) {
219
+ if (!version) return false;
220
+ return String(text || "").includes(docdexBlockStart(version));
221
+ }
222
+
223
+ function stripDocdexBlocks(text) {
224
+ const re = new RegExp(
225
+ `${escapeRegExp(DOCDEX_INFO_START_PREFIX)}[^\\r\\n]* ----\\r?\\n[\\s\\S]*?\\r?\\n${escapeRegExp(
226
+ DOCDEX_INFO_END
227
+ )}\\r?\\n?`,
228
+ "g"
229
+ );
230
+ return String(text || "").replace(re, "").trim();
231
+ }
232
+
233
+ function stripDocdexBlocksExcept(text, version) {
234
+ if (!version) return stripDocdexBlocks(text);
235
+ const source = String(text || "");
236
+ const re = new RegExp(
237
+ `${escapeRegExp(DOCDEX_INFO_START_PREFIX)}[^\\r\\n]* ----\\r?\\n[\\s\\S]*?\\r?\\n${escapeRegExp(
238
+ DOCDEX_INFO_END
239
+ )}\\r?\\n?`,
240
+ "g"
241
+ );
242
+ let result = "";
243
+ let lastIndex = 0;
244
+ let match;
245
+ while ((match = re.exec(source))) {
246
+ const before = source.slice(lastIndex, match.index);
247
+ result += before;
248
+ const block = match[0];
249
+ const blockVersion = extractDocdexBlockVersion(block);
250
+ if (blockVersion === version) {
251
+ result += block;
252
+ }
253
+ lastIndex = match.index + block.length;
254
+ }
255
+ result += source.slice(lastIndex);
256
+ return result;
257
+ }
258
+
259
+ function stripLegacyDocdexBodySegment(segment, body) {
260
+ if (!body) return String(segment || "");
261
+ const normalizedSegment = String(segment || "").replace(/\r\n/g, "\n");
262
+ const normalizedBody = String(body || "").replace(/\r\n/g, "\n");
263
+ if (!normalizedBody.trim()) return normalizedSegment;
264
+ const re = new RegExp(`\\n?${escapeRegExp(normalizedBody)}\\n?`, "g");
265
+ return normalizedSegment.replace(re, "\n").replace(/\n{3,}/g, "\n\n");
266
+ }
267
+
268
+ function stripLegacyDocdexBody(text, body) {
269
+ if (!body) return String(text || "");
270
+ const source = String(text || "").replace(/\r\n/g, "\n");
271
+ const re = new RegExp(
272
+ `${escapeRegExp(DOCDEX_INFO_START_PREFIX)}[^\\n]* ----\\n[\\s\\S]*?\\n${escapeRegExp(DOCDEX_INFO_END)}\\n?`,
273
+ "g"
274
+ );
275
+ let result = "";
276
+ let lastIndex = 0;
277
+ let match;
278
+ while ((match = re.exec(source))) {
279
+ const before = source.slice(lastIndex, match.index);
280
+ result += stripLegacyDocdexBodySegment(before, body);
281
+ result += match[0];
282
+ lastIndex = match.index + match[0].length;
283
+ }
284
+ result += stripLegacyDocdexBodySegment(source.slice(lastIndex), body);
285
+ return result;
286
+ }
287
+
288
+ function mergeInstructionText(existing, instructions, { prepend = false } = {}) {
176
289
  const next = normalizeInstructionText(instructions);
177
290
  if (!next) return normalizeInstructionText(existing);
178
- const current = normalizeInstructionText(existing);
291
+ const existingText = String(existing || "");
292
+ const current = normalizeInstructionText(existingText);
179
293
  if (!current) return next;
180
- if (current.includes(next)) return current;
181
- return `${current}\n\n${next}`;
294
+ const version = extractDocdexBlockVersion(next);
295
+ if (version) {
296
+ const body = extractDocdexBlockBody(next);
297
+ const cleaned = stripLegacyDocdexBody(existingText, body);
298
+ const withoutOldBlocks = stripDocdexBlocksExcept(cleaned, version);
299
+ if (hasDocdexBlockVersion(withoutOldBlocks, version)) return withoutOldBlocks;
300
+ const remainder = normalizeInstructionText(stripDocdexBlocks(withoutOldBlocks));
301
+ if (!remainder) return next;
302
+ return prepend ? `${next}\n\n${remainder}` : `${remainder}\n\n${next}`;
303
+ }
304
+ if (existingText.includes(next)) return existingText;
305
+ return prepend ? `${next}\n\n${current}` : `${current}\n\n${next}`;
182
306
  }
183
307
 
184
308
  function writeTextFile(pathname, contents) {
@@ -199,13 +323,10 @@ function upsertPromptFile(pathname, instructions, { prepend = false } = {}) {
199
323
  let current = "";
200
324
  if (fs.existsSync(pathname)) {
201
325
  current = fs.readFileSync(pathname, "utf8");
202
- if (current.includes(next)) return false;
203
- }
204
- const currentTrimmed = normalizeInstructionText(current);
205
- let merged = next;
206
- if (currentTrimmed) {
207
- merged = prepend ? `${next}\n\n${currentTrimmed}` : `${currentTrimmed}\n\n${next}`;
208
326
  }
327
+ const merged = mergeInstructionText(current, instructions, { prepend });
328
+ if (!merged) return false;
329
+ if (merged === current) return false;
209
330
  return writeTextFile(pathname, merged);
210
331
  }
211
332
 
@@ -220,13 +341,51 @@ function upsertYamlInstruction(pathname, key, instructions) {
220
341
  if (fs.existsSync(pathname)) {
221
342
  current = fs.readFileSync(pathname, "utf8");
222
343
  }
223
- const keyRe = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`, "m");
224
- if (keyRe.test(current)) {
225
- if (current.includes(next)) return false;
226
- return false;
344
+ const lines = current.split(/\r?\n/);
345
+ const blockRe = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*:\\s*(\\|[+-]?)?\\s*$`);
346
+ for (let idx = 0; idx < lines.length; idx += 1) {
347
+ const match = lines[idx].match(blockRe);
348
+ if (!match) continue;
349
+ const indent = match[1] || "";
350
+ const blockIndent = `${indent} `;
351
+ let existingBlock = "";
352
+ let blockEnd = idx + 1;
353
+ if (match[2]) {
354
+ for (let j = idx + 1; j < lines.length; j += 1) {
355
+ const line = lines[j];
356
+ if (!line.trim()) {
357
+ blockEnd = j + 1;
358
+ continue;
359
+ }
360
+ const leading = line.match(/^\s*/)[0].length;
361
+ if (leading <= indent.length) break;
362
+ blockEnd = j + 1;
363
+ }
364
+ const blockLines = lines.slice(idx + 1, blockEnd);
365
+ existingBlock = blockLines
366
+ .map((line) => {
367
+ if (!line.trim()) return "";
368
+ return line.startsWith(blockIndent) ? line.slice(blockIndent.length) : line.trimStart();
369
+ })
370
+ .join("\n");
371
+ } else {
372
+ const inlineMatch = lines[idx].match(new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*:\\s*(.*)$`));
373
+ existingBlock = inlineMatch ? inlineMatch[2].trim() : "";
374
+ }
375
+ const merged = mergeInstructionText(existingBlock, instructions);
376
+ if (!merged) return false;
377
+ if (normalizeInstructionText(merged) === normalizeInstructionText(existingBlock) && match[2]) return false;
378
+ const mergedLines = merged.split(/\r?\n/).map((line) => `${blockIndent}${line}`);
379
+ const updatedLines = [
380
+ ...lines.slice(0, idx),
381
+ `${indent}${key}: |`,
382
+ ...mergedLines,
383
+ ...lines.slice(match[2] ? blockEnd : idx + 1)
384
+ ];
385
+ return writeTextFile(pathname, updatedLines.join("\n").trimEnd());
227
386
  }
228
- const lines = next.split(/\r?\n/).map((line) => ` ${line}`);
229
- const block = `${key}: |\n${lines.join("\n")}`;
387
+ const contentLines = next.split(/\r?\n/).map((line) => ` ${line}`);
388
+ const block = `${key}: |\n${contentLines.join("\n")}`;
230
389
  const merged = current.trim() ? `${current.trim()}\n\n${block}` : block;
231
390
  return writeTextFile(pathname, merged);
232
391
  }
@@ -668,7 +827,7 @@ function resolveBinaryPath({ binaryPath } = {}) {
668
827
  }
669
828
 
670
829
  function applyAgentInstructions({ logger } = {}) {
671
- const instructions = loadAgentInstructions();
830
+ const instructions = buildDocdexInstructionBlock(loadAgentInstructions());
672
831
  if (!normalizeInstructionText(instructions)) return { ok: false, reason: "missing_instructions" };
673
832
  const paths = clientInstructionPaths();
674
833
  let updated = false;
@@ -1305,8 +1464,26 @@ async function maybePromptOllamaModel({
1305
1464
  return { status: "skipped", reason: "invalid_selection" };
1306
1465
  }
1307
1466
 
1467
+ function resolvePlaywrightFetcherPath() {
1468
+ const candidate = path.join(__dirname, "playwright_fetch.js");
1469
+ return fs.existsSync(candidate) ? candidate : null;
1470
+ }
1471
+
1472
+ function buildDaemonEnvPairs({ mcpBinaryPath } = {}) {
1473
+ const pairs = [["DOCDEX_BROWSER_AUTO_INSTALL", "0"]];
1474
+ if (mcpBinaryPath) pairs.push(["DOCDEX_MCP_SERVER_BIN", mcpBinaryPath]);
1475
+ const fetcher = resolvePlaywrightFetcherPath();
1476
+ if (fetcher) pairs.push(["DOCDEX_PLAYWRIGHT_FETCHER", fetcher]);
1477
+ return pairs;
1478
+ }
1479
+
1480
+ function buildDaemonEnv({ mcpBinaryPath } = {}) {
1481
+ return Object.fromEntries(buildDaemonEnvPairs({ mcpBinaryPath }));
1482
+ }
1483
+
1308
1484
  function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger }) {
1309
1485
  if (!binaryPath) return { ok: false, reason: "missing_binary" };
1486
+ const envPairs = buildDaemonEnvPairs({ mcpBinaryPath });
1310
1487
  const args = [
1311
1488
  "daemon",
1312
1489
  "--repo",
@@ -1319,21 +1496,16 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1319
1496
  "warn",
1320
1497
  "--secure-mode=false"
1321
1498
  ];
1322
- const envMcpBin = mcpBinaryPath ? `DOCDEX_MCP_SERVER_BIN=${mcpBinaryPath}` : null;
1323
1499
 
1324
1500
  if (process.platform === "darwin") {
1325
1501
  const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.docdex.daemon.plist");
1326
1502
  const logDir = path.join(os.homedir(), ".docdex", "logs");
1327
1503
  fs.mkdirSync(logDir, { recursive: true });
1328
1504
  const programArgs = [binaryPath, ...args];
1329
- const envVars = [
1330
- " <key>DOCDEX_BROWSER_AUTO_INSTALL</key>\n",
1331
- " <string>0</string>\n"
1332
- ];
1333
- if (mcpBinaryPath) {
1334
- envVars.push(" <key>DOCDEX_MCP_SERVER_BIN</key>\n");
1335
- envVars.push(` <string>${mcpBinaryPath}</string>\n`);
1336
- }
1505
+ const envVars = envPairs.flatMap(([key, value]) => [
1506
+ ` <key>${key}</key>\n`,
1507
+ ` <string>${value}</string>\n`
1508
+ ]);
1337
1509
  const plist = `<?xml version="1.0" encoding="UTF-8"?>\n` +
1338
1510
  `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
1339
1511
  `<plist version="1.0">\n` +
@@ -1375,6 +1547,7 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1375
1547
  const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
1376
1548
  const unitPath = path.join(systemdDir, "docdexd.service");
1377
1549
  fs.mkdirSync(systemdDir, { recursive: true });
1550
+ const envLines = envPairs.map(([key, value]) => `Environment=${key}=${value}`);
1378
1551
  const unit = [
1379
1552
  "[Unit]",
1380
1553
  "Description=Docdex daemon",
@@ -1382,8 +1555,7 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1382
1555
  "",
1383
1556
  "[Service]",
1384
1557
  `ExecStart=${binaryPath} ${args.join(" ")}`,
1385
- "Environment=DOCDEX_BROWSER_AUTO_INSTALL=0",
1386
- envMcpBin ? `Environment=${envMcpBin}` : null,
1558
+ ...envLines,
1387
1559
  "Restart=always",
1388
1560
  "RestartSec=2",
1389
1561
  "",
@@ -1402,10 +1574,7 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1402
1574
  if (process.platform === "win32") {
1403
1575
  const taskName = "Docdex Daemon";
1404
1576
  const joinedArgs = args.map((arg) => `"${arg}"`).join(" ");
1405
- const envParts = ['set "DOCDEX_BROWSER_AUTO_INSTALL=0"'];
1406
- if (mcpBinaryPath) {
1407
- envParts.push(`set "DOCDEX_MCP_SERVER_BIN=${mcpBinaryPath}"`);
1408
- }
1577
+ const envParts = envPairs.map(([key, value]) => `set "${key}=${value}"`);
1409
1578
  const taskArgs =
1410
1579
  `"cmd.exe" /c "${envParts.join(" && ")} && \"${binaryPath}\" ${joinedArgs}"`;
1411
1580
  const create = spawnSync("schtasks", [
@@ -1433,10 +1602,7 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1433
1602
 
1434
1603
  function startDaemonNow({ binaryPath, mcpBinaryPath, port, repoRoot }) {
1435
1604
  if (!binaryPath) return false;
1436
- const extraEnv = {};
1437
- if (mcpBinaryPath) {
1438
- extraEnv.DOCDEX_MCP_SERVER_BIN = mcpBinaryPath;
1439
- }
1605
+ const extraEnv = buildDaemonEnv({ mcpBinaryPath });
1440
1606
  const child = spawn(
1441
1607
  binaryPath,
1442
1608
  [
@@ -1456,7 +1622,6 @@ function startDaemonNow({ binaryPath, mcpBinaryPath, port, repoRoot }) {
1456
1622
  detached: true,
1457
1623
  env: {
1458
1624
  ...process.env,
1459
- DOCDEX_BROWSER_AUTO_INSTALL: "0",
1460
1625
  ...extraEnv
1461
1626
  }
1462
1627
  }
@@ -1688,5 +1853,6 @@ module.exports = {
1688
1853
  canPromptWithTty,
1689
1854
  shouldSkipSetup,
1690
1855
  launchSetupWizard,
1691
- applyAgentInstructions
1856
+ applyAgentInstructions,
1857
+ buildDaemonEnv
1692
1858
  };
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+
3
+ const https = require("node:https");
4
+
5
+ const UPDATE_CHECK_ENV = "DOCDEX_UPDATE_CHECK";
6
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org/docdex/latest";
7
+ const DEFAULT_TIMEOUT_MS = 1500;
8
+ const MAX_RESPONSE_BYTES = 128 * 1024;
9
+
10
+ let hasChecked = false;
11
+
12
+ function normalizeVersion(value) {
13
+ if (typeof value !== "string") return "";
14
+ return value.trim().replace(/^v/i, "");
15
+ }
16
+
17
+ function parseSemver(value) {
18
+ const normalized = normalizeVersion(value);
19
+ const match = normalized.match(
20
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u
21
+ );
22
+ if (!match) return null;
23
+ return {
24
+ major: Number(match[1]),
25
+ minor: Number(match[2]),
26
+ patch: Number(match[3]),
27
+ prerelease: match[4] ? match[4].split(".") : null
28
+ };
29
+ }
30
+
31
+ function compareIdentifiers(left, right) {
32
+ const leftNum = /^[0-9]+$/.test(left) ? Number(left) : null;
33
+ const rightNum = /^[0-9]+$/.test(right) ? Number(right) : null;
34
+
35
+ if (leftNum != null && rightNum != null) {
36
+ if (leftNum === rightNum) return 0;
37
+ return leftNum > rightNum ? 1 : -1;
38
+ }
39
+
40
+ if (leftNum != null) return -1;
41
+ if (rightNum != null) return 1;
42
+ if (left === right) return 0;
43
+ return left > right ? 1 : -1;
44
+ }
45
+
46
+ function comparePrerelease(left, right) {
47
+ if (!left && !right) return 0;
48
+ if (!left) return 1;
49
+ if (!right) return -1;
50
+
51
+ const length = Math.max(left.length, right.length);
52
+ for (let i = 0; i < length; i += 1) {
53
+ const leftId = left[i];
54
+ const rightId = right[i];
55
+ if (leftId == null) return -1;
56
+ if (rightId == null) return 1;
57
+ const result = compareIdentifiers(leftId, rightId);
58
+ if (result !== 0) return result;
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function compareSemver(left, right) {
64
+ if (!left || !right) return null;
65
+ if (left.major !== right.major) return left.major > right.major ? 1 : -1;
66
+ if (left.minor !== right.minor) return left.minor > right.minor ? 1 : -1;
67
+ if (left.patch !== right.patch) return left.patch > right.patch ? 1 : -1;
68
+ return comparePrerelease(left.prerelease, right.prerelease);
69
+ }
70
+
71
+ function isDisabledEnv(value) {
72
+ if (value == null) return false;
73
+ const normalized = String(value).trim().toLowerCase();
74
+ return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
75
+ }
76
+
77
+ function isEnabledEnv(value) {
78
+ if (value == null) return false;
79
+ const normalized = String(value).trim().toLowerCase();
80
+ return normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes";
81
+ }
82
+
83
+ function isInteractive({ stdout, stderr } = {}) {
84
+ return Boolean(stdout?.isTTY || stderr?.isTTY);
85
+ }
86
+
87
+ function shouldCheckForUpdate({ env, stdout, stderr } = {}) {
88
+ const envValue = env?.[UPDATE_CHECK_ENV];
89
+ if (isDisabledEnv(envValue)) return false;
90
+ if (env?.CI && !isEnabledEnv(envValue)) return false;
91
+ if (!isInteractive({ stdout, stderr }) && !isEnabledEnv(envValue)) return false;
92
+ return true;
93
+ }
94
+
95
+ function fetchLatestVersion({
96
+ httpsModule = https,
97
+ registryUrl = DEFAULT_REGISTRY_URL,
98
+ timeoutMs = DEFAULT_TIMEOUT_MS,
99
+ maxBytes = MAX_RESPONSE_BYTES
100
+ } = {}) {
101
+ if (!httpsModule || typeof httpsModule.request !== "function") {
102
+ return Promise.resolve(null);
103
+ }
104
+
105
+ return new Promise((resolve) => {
106
+ let resolved = false;
107
+ const finish = (value) => {
108
+ if (resolved) return;
109
+ resolved = true;
110
+ resolve(value);
111
+ };
112
+
113
+ const req = httpsModule.request(
114
+ registryUrl,
115
+ {
116
+ method: "GET",
117
+ headers: {
118
+ "User-Agent": "docdex-update-check",
119
+ Accept: "application/json"
120
+ }
121
+ },
122
+ (res) => {
123
+ if (!res || res.statusCode !== 200) {
124
+ res?.resume?.();
125
+ finish(null);
126
+ return;
127
+ }
128
+ res.setEncoding?.("utf8");
129
+ let body = "";
130
+ res.on("data", (chunk) => {
131
+ body += chunk;
132
+ if (body.length > maxBytes) {
133
+ req.destroy?.();
134
+ finish(null);
135
+ }
136
+ });
137
+ res.on("end", () => {
138
+ if (!body) {
139
+ finish(null);
140
+ return;
141
+ }
142
+ try {
143
+ const parsed = JSON.parse(body);
144
+ const version = typeof parsed?.version === "string" ? parsed.version : null;
145
+ finish(version);
146
+ } catch {
147
+ finish(null);
148
+ }
149
+ });
150
+ }
151
+ );
152
+
153
+ req.on("error", () => finish(null));
154
+ if (typeof req.setTimeout === "function") {
155
+ req.setTimeout(timeoutMs, () => {
156
+ req.destroy?.();
157
+ finish(null);
158
+ });
159
+ }
160
+ if (typeof req.end === "function") req.end();
161
+ });
162
+ }
163
+
164
+ async function checkForUpdate({
165
+ currentVersion,
166
+ env = process.env,
167
+ stdout = process.stdout,
168
+ stderr = process.stderr,
169
+ logger = console,
170
+ httpsModule = https,
171
+ registryUrl = DEFAULT_REGISTRY_URL,
172
+ timeoutMs = DEFAULT_TIMEOUT_MS,
173
+ maxBytes = MAX_RESPONSE_BYTES
174
+ } = {}) {
175
+ if (!shouldCheckForUpdate({ env, stdout, stderr })) {
176
+ return { checked: false, updateAvailable: false };
177
+ }
178
+
179
+ const current = normalizeVersion(currentVersion);
180
+ if (!current) {
181
+ return { checked: true, updateAvailable: false };
182
+ }
183
+
184
+ const latest = normalizeVersion(
185
+ await fetchLatestVersion({ httpsModule, registryUrl, timeoutMs, maxBytes })
186
+ );
187
+ if (!latest) {
188
+ return { checked: true, updateAvailable: false };
189
+ }
190
+
191
+ const comparison = compareSemver(parseSemver(current), parseSemver(latest));
192
+ if (comparison == null || comparison >= 0) {
193
+ return { checked: true, updateAvailable: false, latestVersion: latest };
194
+ }
195
+
196
+ logger?.log?.(`[docdex] Update available: v${current} -> v${latest}`);
197
+ logger?.log?.("[docdex] Run: npm i -g docdex@latest");
198
+ logger?.log?.(`[docdex] Disable update checks with ${UPDATE_CHECK_ENV}=0`);
199
+
200
+ return { checked: true, updateAvailable: true, latestVersion: latest };
201
+ }
202
+
203
+ async function checkForUpdateOnce(options) {
204
+ if (hasChecked) return { checked: false, updateAvailable: false };
205
+ hasChecked = true;
206
+ return checkForUpdate(options);
207
+ }
208
+
209
+ module.exports = {
210
+ DEFAULT_REGISTRY_URL,
211
+ DEFAULT_TIMEOUT_MS,
212
+ MAX_RESPONSE_BYTES,
213
+ checkForUpdate,
214
+ checkForUpdateOnce,
215
+ compareSemver,
216
+ parseSemver,
217
+ shouldCheckForUpdate
218
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docdex",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "mcpName": "io.github.bekirdag/docdex",
5
5
  "description": "Local-first documentation and code indexer with HTTP/MCP search, AST, and agent memory.",
6
6
  "bin": {