docdex 0.2.2 → 0.2.4
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 +1 -1
- package/README.md +1 -0
- package/lib/install.js +181 -10
- package/lib/postinstall_setup.js +185 -16
- package/lib/uninstall.js +295 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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();
|
package/lib/postinstall_setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
320
|
+
fs.writeFileSync(pathname, contents.endsWith("\n") ? contents : `${contents}\n`);
|
|
177
321
|
return true;
|
|
178
322
|
}
|
|
179
323
|
|
|
@@ -233,12 +377,17 @@ function parseEnvBool(value) {
|
|
|
233
377
|
return null;
|
|
234
378
|
}
|
|
235
379
|
|
|
380
|
+
function hasInteractiveTty(stdin, stdout) {
|
|
381
|
+
return Boolean((stdin && stdin.isTTY) || (stdout && stdout.isTTY));
|
|
382
|
+
}
|
|
383
|
+
|
|
236
384
|
function resolveOllamaInstallMode({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
|
|
237
385
|
const override = parseEnvBool(env.DOCDEX_OLLAMA_INSTALL);
|
|
238
386
|
if (override === true) return { mode: "install", reason: "env", interactive: false };
|
|
239
387
|
if (override === false) return { mode: "skip", reason: "env", interactive: false };
|
|
240
|
-
|
|
241
|
-
|
|
388
|
+
if (!hasInteractiveTty(stdin, stdout)) {
|
|
389
|
+
return { mode: "skip", reason: "non_interactive", interactive: false };
|
|
390
|
+
}
|
|
242
391
|
if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
|
|
243
392
|
return { mode: "prompt", reason: "interactive", interactive: true };
|
|
244
393
|
}
|
|
@@ -249,8 +398,9 @@ function resolveOllamaModelPromptMode({ env = process.env, stdin = process.stdin
|
|
|
249
398
|
if (override === false) return { mode: "skip", reason: "env", interactive: false };
|
|
250
399
|
const assumeYes = parseEnvBool(env.DOCDEX_OLLAMA_MODEL_ASSUME_Y);
|
|
251
400
|
if (assumeYes === true) return { mode: "auto", reason: "env", interactive: false };
|
|
252
|
-
|
|
253
|
-
|
|
401
|
+
if (!hasInteractiveTty(stdin, stdout)) {
|
|
402
|
+
return { mode: "skip", reason: "non_interactive", interactive: false };
|
|
403
|
+
}
|
|
254
404
|
if (env.CI) return { mode: "skip", reason: "ci", interactive: false };
|
|
255
405
|
return { mode: "prompt", reason: "interactive", interactive: true };
|
|
256
406
|
}
|
|
@@ -408,11 +558,27 @@ function isOllamaAvailable() {
|
|
|
408
558
|
return isCommandAvailable("ollama", ["--version"]);
|
|
409
559
|
}
|
|
410
560
|
|
|
561
|
+
function resolvePromptStreams(stdin, stdout) {
|
|
562
|
+
if (hasInteractiveTty(stdin, stdout)) {
|
|
563
|
+
return { input: stdin, output: stdout, close: null };
|
|
564
|
+
}
|
|
565
|
+
const isWindows = process.platform === "win32";
|
|
566
|
+
const ttyPath = isWindows ? "CONIN$" : "/dev/tty";
|
|
567
|
+
try {
|
|
568
|
+
const input = fs.createReadStream(ttyPath, { autoClose: true });
|
|
569
|
+
return { input, output: stdout, close: () => input.close() };
|
|
570
|
+
} catch {
|
|
571
|
+
return { input: stdin, output: stdout, close: null };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
411
575
|
function promptYesNo(question, { defaultYes = true, stdin = process.stdin, stdout = process.stdout } = {}) {
|
|
412
576
|
return new Promise((resolve) => {
|
|
413
|
-
const
|
|
577
|
+
const { input, output, close } = resolvePromptStreams(stdin, stdout);
|
|
578
|
+
const rl = readline.createInterface({ input, output });
|
|
414
579
|
rl.question(question, (answer) => {
|
|
415
580
|
rl.close();
|
|
581
|
+
if (typeof close === "function") close();
|
|
416
582
|
const normalized = String(answer || "").trim().toLowerCase();
|
|
417
583
|
if (!normalized) return resolve(defaultYes);
|
|
418
584
|
resolve(["y", "yes"].includes(normalized));
|
|
@@ -422,9 +588,11 @@ function promptYesNo(question, { defaultYes = true, stdin = process.stdin, stdou
|
|
|
422
588
|
|
|
423
589
|
function promptInput(question, { stdin = process.stdin, stdout = process.stdout } = {}) {
|
|
424
590
|
return new Promise((resolve) => {
|
|
425
|
-
const
|
|
591
|
+
const { input, output, close } = resolvePromptStreams(stdin, stdout);
|
|
592
|
+
const rl = readline.createInterface({ input, output });
|
|
426
593
|
rl.question(question, (answer) => {
|
|
427
594
|
rl.close();
|
|
595
|
+
if (typeof close === "function") close();
|
|
428
596
|
resolve(String(answer || "").trim());
|
|
429
597
|
});
|
|
430
598
|
});
|
|
@@ -881,5 +1049,6 @@ module.exports = {
|
|
|
881
1049
|
readLlmDefaultModel,
|
|
882
1050
|
upsertLlmDefaultModel,
|
|
883
1051
|
pullOllamaModel,
|
|
884
|
-
listOllamaModels
|
|
1052
|
+
listOllamaModels,
|
|
1053
|
+
hasInteractiveTty
|
|
885
1054
|
};
|
package/lib/uninstall.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
return path.join(os.homedir(), ".docdex", "daemon.lock");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clientConfigPaths() {
|
|
25
|
+
const home = os.homedir();
|
|
26
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
27
|
+
const userProfile = process.env.USERPROFILE || home;
|
|
28
|
+
switch (process.platform) {
|
|
29
|
+
case "win32":
|
|
30
|
+
return {
|
|
31
|
+
claude: path.join(appData, "Claude", "claude_desktop_config.json"),
|
|
32
|
+
cursor: path.join(userProfile, ".cursor", "mcp.json"),
|
|
33
|
+
codex: path.join(userProfile, ".codex", "config.toml")
|
|
34
|
+
};
|
|
35
|
+
case "darwin":
|
|
36
|
+
return {
|
|
37
|
+
claude: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
38
|
+
cursor: path.join(home, ".cursor", "mcp.json"),
|
|
39
|
+
codex: path.join(home, ".codex", "config.toml")
|
|
40
|
+
};
|
|
41
|
+
default:
|
|
42
|
+
return {
|
|
43
|
+
claude: path.join(home, ".config", "Claude", "claude_desktop_config.json"),
|
|
44
|
+
cursor: path.join(home, ".cursor", "mcp.json"),
|
|
45
|
+
codex: path.join(home, ".codex", "config.toml")
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson(pathname) {
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(pathname)) return { value: {}, exists: false };
|
|
53
|
+
const raw = fs.readFileSync(pathname, "utf8");
|
|
54
|
+
if (!raw.trim()) return { value: {}, exists: true };
|
|
55
|
+
return { value: JSON.parse(raw), exists: true };
|
|
56
|
+
} catch {
|
|
57
|
+
return { value: {}, exists: true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeJson(pathname, value) {
|
|
62
|
+
fs.mkdirSync(path.dirname(pathname), { recursive: true });
|
|
63
|
+
fs.writeFileSync(pathname, JSON.stringify(value, null, 2) + "\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeMcpServerJson(pathname, name = "docdex") {
|
|
67
|
+
const { value, exists } = readJson(pathname);
|
|
68
|
+
if (!exists || typeof value !== "object" || value == null || Array.isArray(value)) return false;
|
|
69
|
+
const root = value;
|
|
70
|
+
if (!root.mcpServers || typeof root.mcpServers !== "object" || Array.isArray(root.mcpServers)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (!Object.prototype.hasOwnProperty.call(root.mcpServers, name)) return false;
|
|
74
|
+
delete root.mcpServers[name];
|
|
75
|
+
if (Object.keys(root.mcpServers).length === 0) delete root.mcpServers;
|
|
76
|
+
writeJson(pathname, root);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function removeCodexConfig(pathname, name = "docdex") {
|
|
81
|
+
if (!fs.existsSync(pathname)) return false;
|
|
82
|
+
let contents = fs.readFileSync(pathname, "utf8");
|
|
83
|
+
const original = contents;
|
|
84
|
+
|
|
85
|
+
const parseTomlString = (value) => {
|
|
86
|
+
const trimmed = value.trim();
|
|
87
|
+
const quoted = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
|
|
88
|
+
return quoted ? quoted[1] : trimmed;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const removeArrayBlocks = (text) => {
|
|
92
|
+
const lines = text.split(/\r?\n/);
|
|
93
|
+
const output = [];
|
|
94
|
+
let inBlock = false;
|
|
95
|
+
let block = [];
|
|
96
|
+
let blockHasName = false;
|
|
97
|
+
|
|
98
|
+
const flush = () => {
|
|
99
|
+
if (!inBlock) return;
|
|
100
|
+
if (!blockHasName) output.push(...block);
|
|
101
|
+
inBlock = false;
|
|
102
|
+
block = [];
|
|
103
|
+
blockHasName = false;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (/^\s*\[\[mcp_servers\]\]\s*$/.test(line)) {
|
|
108
|
+
flush();
|
|
109
|
+
inBlock = true;
|
|
110
|
+
block = [line];
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (inBlock) {
|
|
114
|
+
if (/^\s*\[.+\]\s*$/.test(line)) {
|
|
115
|
+
flush();
|
|
116
|
+
output.push(line);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const match = line.match(/^\s*name\s*=\s*(.+?)\s*$/);
|
|
120
|
+
if (match && parseTomlString(match[1]) === name) {
|
|
121
|
+
blockHasName = true;
|
|
122
|
+
}
|
|
123
|
+
block.push(line);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
output.push(line);
|
|
127
|
+
}
|
|
128
|
+
flush();
|
|
129
|
+
return output.join("\n");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const removeNestedSection = (text) => {
|
|
133
|
+
const lines = text.split(/\r?\n/);
|
|
134
|
+
const output = [];
|
|
135
|
+
let skip = false;
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
if (/^\s*\[mcp_servers\.docdex\]\s*$/.test(line)) {
|
|
138
|
+
skip = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (skip) {
|
|
142
|
+
if (/^\s*\[.+\]\s*$/.test(line)) {
|
|
143
|
+
skip = false;
|
|
144
|
+
output.push(line);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
output.push(line);
|
|
149
|
+
}
|
|
150
|
+
return output.join("\n");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const removeTableEntry = (text) => {
|
|
154
|
+
const lines = text.split(/\r?\n/);
|
|
155
|
+
const output = [];
|
|
156
|
+
let inTable = false;
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const section = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
159
|
+
if (section) {
|
|
160
|
+
inTable = section[1].trim() === "mcp_servers";
|
|
161
|
+
output.push(line);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (inTable && new RegExp(`^\\s*${name}\\s*=`).test(line)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
output.push(line);
|
|
168
|
+
}
|
|
169
|
+
return output.join("\n");
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
contents = removeArrayBlocks(contents);
|
|
173
|
+
contents = removeNestedSection(contents);
|
|
174
|
+
contents = removeTableEntry(contents);
|
|
175
|
+
|
|
176
|
+
if (contents !== original) {
|
|
177
|
+
fs.writeFileSync(pathname, contents.endsWith("\n") ? contents : `${contents}\n`);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function killPid(pid) {
|
|
184
|
+
if (!pid) return false;
|
|
185
|
+
try {
|
|
186
|
+
if (process.platform === "win32") {
|
|
187
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"]);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
process.kill(pid, "SIGTERM");
|
|
191
|
+
return true;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stopDaemonFromLock() {
|
|
198
|
+
const lockPath = daemonLockPath();
|
|
199
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
200
|
+
try {
|
|
201
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
202
|
+
const payload = JSON.parse(raw);
|
|
203
|
+
const pid = payload && typeof payload.pid === "number" ? payload.pid : null;
|
|
204
|
+
const stopped = killPid(pid);
|
|
205
|
+
fs.unlinkSync(lockPath);
|
|
206
|
+
return stopped;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function unregisterStartup() {
|
|
213
|
+
if (process.platform === "darwin") {
|
|
214
|
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.docdex.daemon.plist");
|
|
215
|
+
if (fs.existsSync(plistPath)) {
|
|
216
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
217
|
+
if (uid != null) {
|
|
218
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}`, plistPath]);
|
|
219
|
+
}
|
|
220
|
+
spawnSync("launchctl", ["unload", "-w", plistPath]);
|
|
221
|
+
spawnSync("launchctl", ["remove", "com.docdex.daemon"]);
|
|
222
|
+
try {
|
|
223
|
+
fs.unlinkSync(plistPath);
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (process.platform === "linux") {
|
|
230
|
+
const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
|
|
231
|
+
const unitPath = path.join(systemdDir, "docdexd.service");
|
|
232
|
+
spawnSync("systemctl", ["--user", "disable", "--now", "docdexd.service"]);
|
|
233
|
+
if (fs.existsSync(unitPath)) {
|
|
234
|
+
try {
|
|
235
|
+
fs.unlinkSync(unitPath);
|
|
236
|
+
} catch {}
|
|
237
|
+
spawnSync("systemctl", ["--user", "daemon-reload"]);
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (process.platform === "win32") {
|
|
243
|
+
spawnSync("schtasks", ["/End", "/TN", DAEMON_TASK_NAME]);
|
|
244
|
+
spawnSync("schtasks", ["/Delete", "/TN", DAEMON_TASK_NAME, "/F"]);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clearStartupFailure() {
|
|
252
|
+
const markerPath = path.join(stateDir(), STARTUP_FAILURE_MARKER);
|
|
253
|
+
if (fs.existsSync(markerPath)) {
|
|
254
|
+
try {
|
|
255
|
+
fs.unlinkSync(markerPath);
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function removeDaemonRootNotice() {
|
|
261
|
+
const root = daemonRootPath();
|
|
262
|
+
const readme = path.join(root, "README.txt");
|
|
263
|
+
if (fs.existsSync(readme)) {
|
|
264
|
+
try {
|
|
265
|
+
fs.unlinkSync(readme);
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function removeClientConfigs() {
|
|
271
|
+
const paths = clientConfigPaths();
|
|
272
|
+
removeMcpServerJson(paths.claude);
|
|
273
|
+
removeMcpServerJson(paths.cursor);
|
|
274
|
+
removeCodexConfig(paths.codex);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
stopDaemonFromLock();
|
|
279
|
+
unregisterStartup();
|
|
280
|
+
removeClientConfigs();
|
|
281
|
+
clearStartupFailure();
|
|
282
|
+
removeDaemonRootNotice();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (require.main === module) {
|
|
286
|
+
main().catch(() => process.exit(0));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
removeMcpServerJson,
|
|
291
|
+
removeCodexConfig,
|
|
292
|
+
stopDaemonFromLock,
|
|
293
|
+
unregisterStartup,
|
|
294
|
+
removeClientConfigs
|
|
295
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docdex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
},
|