docdex 0.2.16 → 0.2.17

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/lib/install.js CHANGED
@@ -8,6 +8,7 @@ const os = require("node:os");
8
8
  const path = require("node:path");
9
9
  const { pipeline } = require("node:stream/promises");
10
10
  const crypto = require("node:crypto");
11
+ const { spawn, spawnSync } = require("node:child_process");
11
12
 
12
13
  const pkg = require("../package.json");
13
14
  const {
@@ -39,6 +40,9 @@ const DEFAULT_INTEGRITY_CONFIG = Object.freeze({
39
40
  });
40
41
  const LOCAL_FALLBACK_ENV = "DOCDEX_LOCAL_FALLBACK";
41
42
  const LOCAL_BINARY_ENV = "DOCDEX_LOCAL_BINARY";
43
+ const AGENTS_DOC_FILENAME = "agents.md";
44
+ const PLAYWRIGHT_INSTALL_GUARD = "DOCDEX_INTERNAL_PLAYWRIGHT_INSTALL";
45
+ const PLAYWRIGHT_SKIP_ENV = "DOCDEX_SKIP_PLAYWRIGHT_DEP_INSTALL";
42
46
 
43
47
  const EXIT_CODE_BY_ERROR_CODE = Object.freeze({
44
48
  DOCDEX_INSTALLER_CONFIG: 2,
@@ -198,6 +202,90 @@ function requestOptions() {
198
202
  return { headers };
199
203
  }
200
204
 
205
+ function agentsDocSourcePath() {
206
+ return path.join(__dirname, "..", "assets", AGENTS_DOC_FILENAME);
207
+ }
208
+
209
+ function agentsDocTargetPath() {
210
+ return path.join(os.homedir(), ".docdex", AGENTS_DOC_FILENAME);
211
+ }
212
+
213
+ function writeAgentInstructions() {
214
+ const sourcePath = agentsDocSourcePath();
215
+ if (!fs.existsSync(sourcePath)) return false;
216
+ let contents = "";
217
+ try {
218
+ contents = fs.readFileSync(sourcePath, "utf8");
219
+ } catch {
220
+ return false;
221
+ }
222
+ if (!contents.trim()) return false;
223
+ const targetPath = agentsDocTargetPath();
224
+ try {
225
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
226
+ const existing = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
227
+ if (existing === contents) return false;
228
+ fs.writeFileSync(targetPath, contents);
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ function resolvePlaywrightPackage() {
236
+ const baseDir = path.join(__dirname, "..");
237
+ try {
238
+ return require.resolve("playwright/package.json", { paths: [baseDir] });
239
+ } catch {}
240
+ try {
241
+ return require.resolve("playwright/package.json");
242
+ } catch {}
243
+ return null;
244
+ }
245
+
246
+ function resolveNpmCommand() {
247
+ const npmExec = process.env.npm_execpath;
248
+ if (npmExec) {
249
+ return { cmd: process.execPath, args: [npmExec] };
250
+ }
251
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
252
+ return { cmd: npmCmd, args: [] };
253
+ }
254
+
255
+ function ensurePlaywrightDependency({ logger = console } = {}) {
256
+ if (process.env[PLAYWRIGHT_SKIP_ENV]) return;
257
+ if (process.env[PLAYWRIGHT_INSTALL_GUARD]) return;
258
+ if (resolvePlaywrightPackage()) return;
259
+
260
+ const rootDir = path.join(__dirname, "..");
261
+ const { cmd, args } = resolveNpmCommand();
262
+ const installArgs = args.concat([
263
+ "install",
264
+ "--no-save",
265
+ "--ignore-scripts",
266
+ "--no-package-lock",
267
+ "--no-audit",
268
+ "--no-fund",
269
+ "playwright"
270
+ ]);
271
+ const result = spawnSync(cmd, installArgs, {
272
+ cwd: rootDir,
273
+ stdio: "inherit",
274
+ env: {
275
+ ...process.env,
276
+ [PLAYWRIGHT_INSTALL_GUARD]: "1"
277
+ }
278
+ });
279
+ if (result.error || (typeof result.status === "number" && result.status !== 0)) {
280
+ const message = result.error?.message || `npm exit status ${result.status}`;
281
+ logger.warn?.(`[docdex] Playwright dependency install failed: ${message}`);
282
+ return;
283
+ }
284
+ if (!resolvePlaywrightPackage()) {
285
+ logger.warn?.("[docdex] Playwright dependency still missing after install attempt");
286
+ }
287
+ }
288
+
201
289
  function selectHttpClient(url) {
202
290
  try {
203
291
  const protocol = new URL(url).protocol;
@@ -294,11 +382,48 @@ function download(url, dest, redirects = 0) {
294
382
 
295
383
  async function extractTarball(archivePath, targetDir) {
296
384
  // Lazy import so unit tests can load this module without installing optional npm deps.
297
- const tar = require("tar");
385
+ let tar;
386
+ try {
387
+ tar = require("tar");
388
+ } catch (err) {
389
+ if (err && err.code === "MODULE_NOT_FOUND") {
390
+ await extractTarballWithSystemTar(archivePath, targetDir);
391
+ return;
392
+ }
393
+ throw err;
394
+ }
298
395
  await fs.promises.mkdir(targetDir, { recursive: true });
299
396
  await tar.x({ file: archivePath, cwd: targetDir, gzip: true });
300
397
  }
301
398
 
399
+ async function extractTarballWithSystemTar(archivePath, targetDir) {
400
+ await fs.promises.mkdir(targetDir, { recursive: true });
401
+ const args = ["-xzf", archivePath, "-C", targetDir];
402
+ await new Promise((resolve, reject) => {
403
+ const proc = spawn("tar", args, { stdio: "ignore" });
404
+ proc.on("error", (err) => {
405
+ reject(
406
+ new ArchiveInvalidError(
407
+ `tar module missing and system tar failed: ${err.message}`,
408
+ { archivePath }
409
+ )
410
+ );
411
+ });
412
+ proc.on("close", (code) => {
413
+ if (code === 0) {
414
+ resolve();
415
+ } else {
416
+ reject(
417
+ new ArchiveInvalidError(
418
+ `system tar exited with code ${code}`,
419
+ { archivePath }
420
+ )
421
+ );
422
+ }
423
+ });
424
+ });
425
+ }
426
+
302
427
  async function sha256File(filePath) {
303
428
  return new Promise((resolve, reject) => {
304
429
  const hash = crypto.createHash("sha256");
@@ -371,6 +496,45 @@ function parseEnvBool(value) {
371
496
  return null;
372
497
  }
373
498
 
499
+ function readJsonSync({ fsModule, filePath }) {
500
+ try {
501
+ const raw = fsModule.readFileSync(filePath, "utf8");
502
+ return JSON.parse(raw);
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ function readTextSync({ fsModule, filePath }) {
509
+ try {
510
+ return fsModule.readFileSync(filePath, "utf8");
511
+ } catch {
512
+ return null;
513
+ }
514
+ }
515
+
516
+ function isDocdexRepoRoot({ baseDir, fsModule, pathModule }) {
517
+ const cargoPath = pathModule.join(baseDir, "Cargo.toml");
518
+ const npmPackagePath = pathModule.join(baseDir, "npm", "package.json");
519
+ if (!fsModule.existsSync(cargoPath) || !fsModule.existsSync(npmPackagePath)) return false;
520
+ const pkgJson = readJsonSync({ fsModule, filePath: npmPackagePath });
521
+ if (pkgJson?.name !== "docdex") return false;
522
+ const cargoToml = readTextSync({ fsModule, filePath: cargoPath });
523
+ return Boolean(cargoToml && /name\s*=\s*"docdexd"/.test(cargoToml));
524
+ }
525
+
526
+ function detectLocalRepoRootFromInitCwd({ env = process.env, fsModule = fs, pathModule = path } = {}) {
527
+ const initCwd = env?.INIT_CWD;
528
+ if (!initCwd) return null;
529
+ const candidate = pathModule.resolve(initCwd);
530
+ if (isDocdexRepoRoot({ baseDir: candidate, fsModule, pathModule })) return candidate;
531
+ const parent = pathModule.dirname(candidate);
532
+ if (parent && parent !== candidate && isDocdexRepoRoot({ baseDir: parent, fsModule, pathModule })) {
533
+ return parent;
534
+ }
535
+ return null;
536
+ }
537
+
374
538
  function detectLocalRepoRoot({ pathModule, fsModule } = {}) {
375
539
  const pathImpl = pathModule || path;
376
540
  const fsImpl = fsModule || fs;
@@ -383,6 +547,45 @@ function detectLocalRepoRoot({ pathModule, fsModule } = {}) {
383
547
  return null;
384
548
  }
385
549
 
550
+ function parseNpmConfigArgv(env) {
551
+ const raw = env?.npm_config_argv;
552
+ if (!raw || typeof raw !== "string") return null;
553
+ try {
554
+ const parsed = JSON.parse(raw);
555
+ if (Array.isArray(parsed?.original)) return parsed.original;
556
+ if (Array.isArray(parsed?.cooked)) return parsed.cooked;
557
+ } catch {}
558
+ return null;
559
+ }
560
+
561
+ function isLikelyLocalInstallArg(arg, pathModule) {
562
+ if (typeof arg !== "string" || !arg) return false;
563
+ if (arg === "." || arg === "..") return true;
564
+ if (arg.startsWith("file:")) return true;
565
+ if (arg.startsWith("./") || arg.startsWith("../")) return true;
566
+ if (pathModule.isAbsolute(arg)) return true;
567
+ return false;
568
+ }
569
+
570
+ function isNpmInstallCommand(argv) {
571
+ return argv.some((arg) => arg === "install" || arg === "i" || arg === "add");
572
+ }
573
+
574
+ function isLocalInstallRequest({ env, pathModule }) {
575
+ const argv = parseNpmConfigArgv(env);
576
+ if (!argv || !isNpmInstallCommand(argv)) return false;
577
+ return argv.some((arg) => isLikelyLocalInstallArg(arg, pathModule));
578
+ }
579
+
580
+ function shouldPreferLocalInstall({ env, localBinaryPath, pathModule }) {
581
+ if (!localBinaryPath) return false;
582
+ if (parseEnvBool(env?.[LOCAL_FALLBACK_ENV]) === false) return false;
583
+ if (env?.[LOCAL_BINARY_ENV]) return true;
584
+ if (!env?.INIT_CWD) return false;
585
+ if (env?.npm_lifecycle_event !== "postinstall") return false;
586
+ return isLocalInstallRequest({ env, pathModule });
587
+ }
588
+
386
589
  function resolveLocalBinaryCandidate({
387
590
  env = process.env,
388
591
  platform = process.platform,
@@ -395,16 +598,36 @@ function resolveLocalBinaryCandidate({
395
598
  const resolved = pathModule.resolve(explicit);
396
599
  if (fsModule.existsSync(resolved)) return resolved;
397
600
  }
601
+ const isWin32 = platform === "win32";
602
+ const mcpName = isWin32 ? "docdex-mcp-server.exe" : "docdex-mcp-server";
398
603
  const root = repoRoot || detectLocalRepoRoot({ pathModule, fsModule });
399
604
  if (!root) return null;
400
605
  const binaryName = platform === "win32" ? "docdexd.exe" : "docdexd";
401
606
  const releasePath = pathModule.join(root, "target", "release", binaryName);
402
- if (fsModule.existsSync(releasePath)) return releasePath;
607
+ if (fsModule.existsSync(releasePath)) {
608
+ if (localMcpPresent({ fsModule, pathModule, binaryPath: releasePath, mcpName })) {
609
+ return releasePath;
610
+ }
611
+ }
403
612
  const debugPath = pathModule.join(root, "target", "debug", binaryName);
404
- if (fsModule.existsSync(debugPath)) return debugPath;
613
+ if (fsModule.existsSync(debugPath)) {
614
+ if (localMcpPresent({ fsModule, pathModule, binaryPath: debugPath, mcpName })) {
615
+ return debugPath;
616
+ }
617
+ }
405
618
  return null;
406
619
  }
407
620
 
621
+ function localMcpPresent({ fsModule, pathModule, binaryPath, mcpName }) {
622
+ const dir = pathModule.dirname(binaryPath);
623
+ const candidates = [
624
+ pathModule.join(dir, mcpName),
625
+ pathModule.join(pathModule.dirname(dir), "release", mcpName),
626
+ pathModule.join(pathModule.dirname(dir), "debug", mcpName)
627
+ ];
628
+ return candidates.some((candidate) => fsModule.existsSync(candidate));
629
+ }
630
+
408
631
  async function installFromLocalBinary({
409
632
  fsModule,
410
633
  pathModule,
@@ -428,13 +651,20 @@ async function installFromLocalBinary({
428
651
  await fsModule.promises.chmod(destPath, 0o755).catch(() => {});
429
652
  }
430
653
  const mcpName = isWin32 ? "docdex-mcp-server.exe" : "docdex-mcp-server";
431
- const mcpSource = pathModule.join(pathModule.dirname(binaryPath), mcpName);
432
- if (fsModule.existsSync(mcpSource)) {
654
+ const mcpCandidates = [
655
+ pathModule.join(pathModule.dirname(binaryPath), mcpName),
656
+ pathModule.join(pathModule.dirname(pathModule.dirname(binaryPath)), "release", mcpName),
657
+ pathModule.join(pathModule.dirname(pathModule.dirname(binaryPath)), "debug", mcpName)
658
+ ];
659
+ const mcpSource = mcpCandidates.find((candidate) => fsModule.existsSync(candidate));
660
+ if (mcpSource) {
433
661
  const mcpDest = pathModule.join(distDir, mcpName);
434
662
  await fsModule.promises.copyFile(mcpSource, mcpDest);
435
663
  if (!isWin32) {
436
664
  await fsModule.promises.chmod(mcpDest, 0o755).catch(() => {});
437
665
  }
666
+ } else {
667
+ logger?.warn?.(`[docdex] local MCP binary not found; expected near ${binaryPath}`);
438
668
  }
439
669
  const binarySha256 = await sha256FileFn(destPath);
440
670
  const metadata = {
@@ -844,6 +1074,9 @@ function decideInstallAction({
844
1074
  integrityResult
845
1075
  }) {
846
1076
  if (!discoveredInstalledState?.binaryPresent) return { outcome: "update", reason: "binary_missing" };
1077
+ if (discoveredInstalledState.mcpBinaryPresent === false) {
1078
+ return { outcome: "update", reason: "mcp_binary_missing" };
1079
+ }
847
1080
 
848
1081
  if (discoveredInstalledState.metadataStatus !== "valid") {
849
1082
  return {
@@ -878,6 +1111,10 @@ function decideInstallAction({
878
1111
 
879
1112
  async function discoverInstalledState({ fsModule, pathModule, distDir, platformKey, isWin32 }) {
880
1113
  const binaryPath = pathModule.join(distDir, isWin32 ? "docdexd.exe" : "docdexd");
1114
+ const mcpBinaryPath = pathModule.join(
1115
+ distDir,
1116
+ isWin32 ? "docdex-mcp-server.exe" : "docdex-mcp-server"
1117
+ );
881
1118
  const metadataPath = installMetadataPath(distDir, pathModule);
882
1119
 
883
1120
  const existsSync = typeof fsModule?.existsSync === "function" ? fsModule.existsSync.bind(fsModule) : null;
@@ -886,6 +1123,7 @@ async function discoverInstalledState({ fsModule, pathModule, distDir, platformK
886
1123
  binaryPath,
887
1124
  metadataPath,
888
1125
  binaryPresent: false,
1126
+ mcpBinaryPresent: false,
889
1127
  installedVersion: null,
890
1128
  metadata: null,
891
1129
  metadataStatus: "unavailable",
@@ -899,6 +1137,7 @@ async function discoverInstalledState({ fsModule, pathModule, distDir, platformK
899
1137
  binaryPath,
900
1138
  metadataPath,
901
1139
  binaryPresent: false,
1140
+ mcpBinaryPresent: false,
902
1141
  installedVersion: null,
903
1142
  metadata: null,
904
1143
  metadataStatus: "missing",
@@ -907,11 +1146,13 @@ async function discoverInstalledState({ fsModule, pathModule, distDir, platformK
907
1146
  };
908
1147
  }
909
1148
 
1149
+ const mcpBinaryPresent = existsSync(mcpBinaryPath);
910
1150
  const metaResult = await readJsonFileIfPossible({ fsModule, filePath: metadataPath });
911
1151
  const meta = metaResult.value;
912
1152
  if (!isValidInstallMetadata(meta)) {
913
1153
  return {
914
1154
  binaryPath,
1155
+ mcpBinaryPresent,
915
1156
  metadataPath,
916
1157
  binaryPresent: true,
917
1158
  installedVersion: typeof meta?.version === "string" ? meta.version : null,
@@ -934,6 +1175,7 @@ async function discoverInstalledState({ fsModule, pathModule, distDir, platformK
934
1175
 
935
1176
  return {
936
1177
  binaryPath,
1178
+ mcpBinaryPresent,
937
1179
  metadataPath,
938
1180
  binaryPresent: true,
939
1181
  installedVersion: meta.version,
@@ -1007,6 +1249,7 @@ async function determineLocalInstallerOutcome({
1007
1249
 
1008
1250
  const shouldVerifyIntegrity =
1009
1251
  discoveredInstalledState.binaryPresent &&
1252
+ discoveredInstalledState.mcpBinaryPresent !== false &&
1010
1253
  !discoveredInstalledState.platformMismatch &&
1011
1254
  discoveredInstalledState.installedVersion === expectedVersion &&
1012
1255
  (normalizeSha256Hex(expectedBinarySha256) || discoveredInstalledState.metadataStatus === "valid");
@@ -1476,6 +1719,7 @@ async function verifyDownloadedFileIntegrity({
1476
1719
  async function runInstaller(options) {
1477
1720
  const opts = options || {};
1478
1721
  const logger = opts.logger || console;
1722
+ const env = opts.env || process.env;
1479
1723
 
1480
1724
  const detectPlatformKeyFn = opts.detectPlatformKeyFn || detectPlatformKey;
1481
1725
  const targetTripleForPlatformKeyFn = opts.targetTripleForPlatformKeyFn || targetTripleForPlatformKey;
@@ -1494,8 +1738,19 @@ async function runInstaller(options) {
1494
1738
  const sha256FileFn = opts.sha256FileFn || sha256File;
1495
1739
  const writeJsonFileAtomicFn = opts.writeJsonFileAtomicFn || writeJsonFileAtomic;
1496
1740
  const restartFn = opts.restartFn;
1497
- const localRepoRoot = opts.localRepoRoot;
1498
- const localBinaryPath = opts.localBinaryPath;
1741
+ const localRepoRoot =
1742
+ opts.localRepoRoot ||
1743
+ detectLocalRepoRootFromInitCwd({ env, fsModule, pathModule }) ||
1744
+ detectLocalRepoRoot({ pathModule, fsModule });
1745
+ const localBinaryPath =
1746
+ opts.localBinaryPath ||
1747
+ resolveLocalBinaryCandidate({
1748
+ env,
1749
+ platform: process.platform,
1750
+ pathModule,
1751
+ fsModule,
1752
+ repoRoot: localRepoRoot
1753
+ });
1499
1754
 
1500
1755
  const detectedPlatform = opts.platform || process.platform;
1501
1756
  const detectedArch = opts.arch || process.arch;
@@ -1551,6 +1806,25 @@ async function runInstaller(options) {
1551
1806
 
1552
1807
  const priorRunnable = existsSync ? existsSync(local.binaryPath) : false;
1553
1808
 
1809
+ const forceLocalBinary = Boolean(env?.[LOCAL_BINARY_ENV]);
1810
+ if (forceLocalBinary && localBinaryPath) {
1811
+ const localInstall = await installFromLocalBinary({
1812
+ fsModule,
1813
+ pathModule,
1814
+ distDir,
1815
+ binaryPath: localBinaryPath,
1816
+ isWin32,
1817
+ version,
1818
+ platformKey,
1819
+ targetTriple,
1820
+ repoSlug: null,
1821
+ sha256FileFn,
1822
+ writeJsonFileAtomicFn,
1823
+ logger
1824
+ });
1825
+ return localInstall;
1826
+ }
1827
+
1554
1828
  if (local.outcome === "no-op") {
1555
1829
  logger.log("[docdex] Install outcome: no-op");
1556
1830
  await cleanupInstallArtifacts({
@@ -1579,6 +1853,24 @@ async function runInstaller(options) {
1579
1853
  };
1580
1854
  }
1581
1855
 
1856
+ if (shouldPreferLocalInstall({ env, localBinaryPath, pathModule })) {
1857
+ const localInstall = await installFromLocalBinary({
1858
+ fsModule,
1859
+ pathModule,
1860
+ distDir,
1861
+ binaryPath: localBinaryPath,
1862
+ isWin32,
1863
+ version,
1864
+ platformKey,
1865
+ targetTriple,
1866
+ repoSlug: null,
1867
+ sha256FileFn,
1868
+ writeJsonFileAtomicFn,
1869
+ logger
1870
+ });
1871
+ return localInstall;
1872
+ }
1873
+
1582
1874
  let repoSlug = null;
1583
1875
  let archive;
1584
1876
  let expectedSha256;
@@ -1601,7 +1893,7 @@ async function runInstaller(options) {
1601
1893
  } catch (err) {
1602
1894
  const fallback = await maybeInstallLocalFallback({
1603
1895
  err,
1604
- env: process.env,
1896
+ env,
1605
1897
  fsModule,
1606
1898
  pathModule,
1607
1899
  distDir,
@@ -1899,6 +2191,16 @@ async function main() {
1899
2191
  } catch (err) {
1900
2192
  console.warn(`[docdex] postinstall setup skipped: ${err?.message || err}`);
1901
2193
  }
2194
+ try {
2195
+ writeAgentInstructions();
2196
+ } catch (err) {
2197
+ console.warn(`[docdex] agent instructions skipped: ${err?.message || err}`);
2198
+ }
2199
+ try {
2200
+ ensurePlaywrightDependency();
2201
+ } catch (err) {
2202
+ console.warn(`[docdex] playwright dependency check skipped: ${err?.message || err}`);
2203
+ }
1902
2204
  printPostInstallBanner();
1903
2205
  }
1904
2206
 
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { chromium, firefox, webkit } = require("playwright");
5
+
6
+ const DEFAULT_TIMEOUT_MS = 15000;
7
+ const DEFAULT_BROWSER = "chromium";
8
+ const VIEWPORT = { width: 1920, height: 1080 };
9
+ const CHROMIUM_ARGS = [
10
+ "--disable-blink-features=AutomationControlled",
11
+ "--disable-dev-shm-usage",
12
+ "--disable-gpu",
13
+ "--no-sandbox",
14
+ "--no-first-run",
15
+ "--no-default-browser-check"
16
+ ];
17
+
18
+ function normalizeBrowser(value) {
19
+ const trimmed = String(value || "").trim().toLowerCase();
20
+ if (trimmed === "chrome" || trimmed === "chromium" || trimmed === "chromium-browser") {
21
+ return "chromium";
22
+ }
23
+ if (trimmed === "firefox") return "firefox";
24
+ if (trimmed === "webkit") return "webkit";
25
+ return DEFAULT_BROWSER;
26
+ }
27
+
28
+ function resolveBrowserType(name) {
29
+ switch (name) {
30
+ case "chromium":
31
+ return chromium;
32
+ case "firefox":
33
+ return firefox;
34
+ case "webkit":
35
+ return webkit;
36
+ default:
37
+ return chromium;
38
+ }
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ const parsed = {
43
+ url: "",
44
+ browser: DEFAULT_BROWSER,
45
+ timeoutMs: DEFAULT_TIMEOUT_MS,
46
+ userAgent: "",
47
+ headless: true,
48
+ userDataDir: ""
49
+ };
50
+ for (let i = 0; i < argv.length; i += 1) {
51
+ const value = argv[i];
52
+ if (value === "--url" && argv[i + 1]) {
53
+ parsed.url = argv[i + 1];
54
+ i += 1;
55
+ continue;
56
+ }
57
+ if (value === "--browser" && argv[i + 1]) {
58
+ parsed.browser = argv[i + 1];
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (value === "--timeout-ms" && argv[i + 1]) {
63
+ parsed.timeoutMs = Number(argv[i + 1]);
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (value === "--user-agent" && argv[i + 1]) {
68
+ parsed.userAgent = argv[i + 1];
69
+ i += 1;
70
+ continue;
71
+ }
72
+ if (value === "--user-data-dir" && argv[i + 1]) {
73
+ parsed.userDataDir = argv[i + 1];
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (value === "--headless") {
78
+ parsed.headless = true;
79
+ continue;
80
+ }
81
+ if (value === "--headed") {
82
+ parsed.headless = false;
83
+ continue;
84
+ }
85
+ }
86
+ parsed.browser = normalizeBrowser(parsed.browser);
87
+ if (!parsed.url) {
88
+ throw new Error("missing --url");
89
+ }
90
+ if (!Number.isFinite(parsed.timeoutMs) || parsed.timeoutMs <= 0) {
91
+ parsed.timeoutMs = DEFAULT_TIMEOUT_MS;
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ async function fetchWithPlaywright(options) {
97
+ const browserName = normalizeBrowser(options.browser);
98
+ const browserType = resolveBrowserType(browserName);
99
+ const launchOptions = {
100
+ headless: options.headless
101
+ };
102
+ if (browserName === "chromium") {
103
+ launchOptions.args = CHROMIUM_ARGS;
104
+ }
105
+
106
+ let browser;
107
+ let context;
108
+ try {
109
+ if (options.userDataDir) {
110
+ context = await browserType.launchPersistentContext(options.userDataDir, {
111
+ ...launchOptions,
112
+ viewport: VIEWPORT,
113
+ userAgent: options.userAgent || undefined
114
+ });
115
+ } else {
116
+ browser = await browserType.launch(launchOptions);
117
+ context = await browser.newContext({
118
+ viewport: VIEWPORT,
119
+ userAgent: options.userAgent || undefined
120
+ });
121
+ }
122
+
123
+ await context.addInitScript(() => {
124
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
125
+ });
126
+
127
+ const page = await context.newPage();
128
+ page.setDefaultTimeout(options.timeoutMs);
129
+ const response = await page.goto(options.url, {
130
+ waitUntil: "domcontentloaded",
131
+ timeout: options.timeoutMs
132
+ });
133
+ const html = await page.content();
134
+ const status = response ? response.status() : null;
135
+ const finalUrl = page.url();
136
+ await page.close();
137
+
138
+ if (!html || !String(html).trim()) {
139
+ throw new Error("empty HTML response");
140
+ }
141
+
142
+ return { html: String(html), status, final_url: finalUrl };
143
+ } finally {
144
+ if (context) {
145
+ await context.close();
146
+ }
147
+ if (browser) {
148
+ await browser.close();
149
+ }
150
+ }
151
+ }
152
+
153
+ async function main() {
154
+ try {
155
+ const options = parseArgs(process.argv.slice(2));
156
+ const result = await fetchWithPlaywright(options);
157
+ process.stdout.write(JSON.stringify(result) + "\n");
158
+ } catch (err) {
159
+ const message = err?.message || String(err);
160
+ console.error(`[docdex] playwright fetch failed: ${message}`);
161
+ process.exit(1);
162
+ }
163
+ }
164
+
165
+ if (require.main === module) {
166
+ main();
167
+ }
168
+
169
+ module.exports = {
170
+ fetchWithPlaywright,
171
+ normalizeBrowser,
172
+ parseArgs,
173
+ resolveBrowserType
174
+ };