@wonderwhy-er/desktop-commander 0.2.41 → 0.2.43

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.
Files changed (45) hide show
  1. package/README.md +0 -2
  2. package/dist/handlers/filesystem-handlers.js +24 -4
  3. package/dist/remote-device/remote-channel.d.ts +14 -0
  4. package/dist/remote-device/remote-channel.js +107 -28
  5. package/dist/server.d.ts +6 -1
  6. package/dist/server.js +112 -25
  7. package/dist/setup-claude-server.js +56 -50
  8. package/dist/terminal-manager.d.ts +18 -0
  9. package/dist/terminal-manager.js +130 -18
  10. package/dist/tools/edit.js +11 -4
  11. package/dist/tools/filesystem.d.ts +5 -0
  12. package/dist/tools/filesystem.js +43 -0
  13. package/dist/tools/fuzzySearch.d.ts +10 -20
  14. package/dist/tools/fuzzySearch.js +66 -105
  15. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  16. package/dist/tools/fuzzySearchCore.js +125 -0
  17. package/dist/tools/improved-process-tools.js +8 -1
  18. package/dist/tools/pdf/markdown.d.ts +13 -0
  19. package/dist/tools/pdf/markdown.js +93 -29
  20. package/dist/tools/schemas.d.ts +20 -0
  21. package/dist/tools/schemas.js +45 -2
  22. package/dist/track-installation.js +57 -38
  23. package/dist/types.d.ts +6 -1
  24. package/dist/ui/contracts.d.ts +1 -1
  25. package/dist/ui/contracts.js +4 -1
  26. package/dist/ui/file-preview/preview-runtime.js +111 -113
  27. package/dist/ui/file-preview/src/app.js +19 -17
  28. package/dist/ui/file-preview/src/directory-controller.js +3 -3
  29. package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
  30. package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
  31. package/dist/ui/file-preview/src/host/external-actions.js +0 -39
  32. package/dist/ui/file-preview/src/markdown/controller.js +3 -1
  33. package/dist/ui/file-preview/src/panel-actions.js +2 -2
  34. package/dist/uninstall-claude-server.js +54 -47
  35. package/dist/utils/ab-test.d.ts +4 -0
  36. package/dist/utils/ab-test.js +6 -0
  37. package/dist/utils/capture.d.ts +10 -2
  38. package/dist/utils/capture.js +92 -60
  39. package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
  40. package/dist/utils/mcp-ui-ab-test.js +62 -0
  41. package/dist/utils/unsupportedParams.d.ts +24 -0
  42. package/dist/utils/unsupportedParams.js +66 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
@@ -1,113 +1,74 @@
1
- import { distance } from 'fastest-levenshtein';
2
1
  import { capture } from '../utils/capture.js';
2
+ import { Worker } from 'worker_threads';
3
+ // Re-export so existing callers keep importing from this module.
4
+ export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
5
+ /** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
6
+ export const FUZZY_SEARCH_TIMEOUT_MS = 30000;
3
7
  /**
4
- * Recursively finds the closest match to a query string within text using fuzzy matching
5
- * @param text The text to search within
6
- * @param query The query string to find
7
- * @param start Start index in the text (default: 0)
8
- * @param end End index in the text (default: text.length)
9
- * @param parentDistance Best distance found so far (default: Infinity)
10
- * @returns Object with start and end indices, matched value, and Levenshtein distance
8
+ * Inline worker entry: imports the dependency-free core module (passed as
9
+ * moduleUrl) and runs the search off the main thread. The core module is
10
+ * deliberately a leaf importing this module (or anything app-level) from the
11
+ * worker would boot the whole server per search. Kept as an eval'd snippet so
12
+ * the worker needs no separate file to ship alongside the compiled output.
11
13
  */
12
- export function recursiveFuzzyIndexOf(text, query, start = 0, end = null, parentDistance = Infinity, depth = 0) {
13
- // For debugging and performance tracking purposes
14
- if (depth === 0) {
15
- const startTime = performance.now();
16
- const result = recursiveFuzzyIndexOf(text, query, start, end, parentDistance, depth + 1);
17
- const executionTime = performance.now() - startTime;
18
- // Capture detailed metrics for the recursive search for in-depth analysis
19
- capture('fuzzy_search_recursive_metrics', {
20
- execution_time_ms: executionTime,
21
- text_length: text.length,
22
- query_length: query.length,
23
- result_distance: result.distance
24
- });
25
- return result;
26
- }
27
- if (end === null)
28
- end = text.length;
29
- // For small text segments, use iterative approach
30
- if (end - start <= 2 * query.length) {
31
- return iterativeReduction(text, query, start, end, parentDistance);
32
- }
33
- let midPoint = start + Math.floor((end - start) / 2);
34
- let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps
35
- let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps
36
- // Calculate distance for current segments
37
- let leftDistance = distance(text.substring(start, leftEnd), query);
38
- let rightDistance = distance(text.substring(rightStart, end), query);
39
- let bestDistance = Math.min(leftDistance, parentDistance, rightDistance);
40
- // If parent distance is already the best, use iterative approach
41
- if (parentDistance === bestDistance) {
42
- return iterativeReduction(text, query, start, end, parentDistance);
43
- }
44
- // Recursively search the better half
45
- if (leftDistance < rightDistance) {
46
- return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance, depth + 1);
47
- }
48
- else {
49
- return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance, depth + 1);
50
- }
51
- }
14
+ const WORKER_CODE = `
15
+ const { workerData, parentPort } = require('worker_threads');
16
+ import(workerData.moduleUrl)
17
+ .then((m) => {
18
+ parentPort.postMessage({ ok: true, ...m.runFuzzySearch(workerData.text, workerData.query) });
19
+ })
20
+ .catch((err) => {
21
+ parentPort.postMessage({ ok: false, error: String(err && err.stack || err) });
22
+ });
23
+ `;
24
+ const CORE_MODULE_URL = new URL('./fuzzySearchCore.js', import.meta.url).href;
52
25
  /**
53
- * Iteratively refines the best match by reducing the search area
54
- * @param text The text to search within
55
- * @param query The query string to find
56
- * @param start Start index in the text
57
- * @param end End index in the text
58
- * @param parentDistance Best distance found so far
59
- * @returns Object with start and end indices, matched value, and Levenshtein distance
26
+ * Runs the fuzzy search in a Worker thread so the main MCP event loop stays
27
+ * responsive to pings and other tool calls during heavy scans. Rejects if the
28
+ * scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
29
+ * background. Search metrics come back with the result and are captured here,
30
+ * on the main thread, where the client identity is initialized.
60
31
  */
61
- function iterativeReduction(text, query, start, end, parentDistance) {
62
- const startTime = performance.now();
63
- let iterations = 0;
64
- let bestDistance = parentDistance;
65
- let bestStart = start;
66
- let bestEnd = end;
67
- // Improve start position
68
- let nextDistance = distance(text.substring(bestStart + 1, bestEnd), query);
69
- while (nextDistance < bestDistance) {
70
- bestDistance = nextDistance;
71
- bestStart++;
72
- const smallerString = text.substring(bestStart + 1, bestEnd);
73
- nextDistance = distance(smallerString, query);
74
- iterations++;
75
- }
76
- // Improve end position
77
- nextDistance = distance(text.substring(bestStart, bestEnd - 1), query);
78
- while (nextDistance < bestDistance) {
79
- bestDistance = nextDistance;
80
- bestEnd--;
81
- const smallerString = text.substring(bestStart, bestEnd - 1);
82
- nextDistance = distance(smallerString, query);
83
- iterations++;
84
- }
85
- const executionTime = performance.now() - startTime;
86
- // Capture metrics for the iterative refinement phase
87
- capture('fuzzy_search_iterative_metrics', {
88
- execution_time_ms: executionTime,
89
- iterations: iterations,
90
- segment_length: end - start,
91
- query_length: query.length,
92
- final_distance: bestDistance
32
+ export function runFuzzySearchInWorker(text, query, timeoutMs = FUZZY_SEARCH_TIMEOUT_MS) {
33
+ return new Promise((resolve, reject) => {
34
+ const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: CORE_MODULE_URL, text, query } });
35
+ // Never let a scan keep the server process alive during shutdown.
36
+ worker.unref();
37
+ const timer = setTimeout(() => {
38
+ worker.terminate();
39
+ reject(new Error(`Fuzzy search timed out after ${timeoutMs}ms`));
40
+ }, timeoutMs);
41
+ timer.unref();
42
+ worker.on('message', (msg) => {
43
+ clearTimeout(timer);
44
+ if (msg.ok) {
45
+ captureFuzzySearchMetrics(msg.metrics);
46
+ resolve(msg.result);
47
+ }
48
+ else {
49
+ reject(new Error(`Fuzzy search worker failed: ${msg.error}`));
50
+ }
51
+ // Don't let the worker wind down on its own; the answer is already
52
+ // here, and a lingering worker holds its copy of the file text.
53
+ // The promise is settled, so the exit-code rejection below is a no-op.
54
+ worker.terminate();
55
+ });
56
+ worker.on('error', (err) => {
57
+ clearTimeout(timer);
58
+ reject(err);
59
+ });
60
+ worker.on('exit', (code) => {
61
+ clearTimeout(timer);
62
+ if (code !== 0) {
63
+ reject(new Error(`Fuzzy search worker exited with code ${code}`));
64
+ }
65
+ });
93
66
  });
94
- return {
95
- start: bestStart,
96
- end: bestEnd,
97
- value: text.substring(bestStart, bestEnd),
98
- distance: bestDistance
99
- };
100
67
  }
101
- /**
102
- * Calculates the similarity ratio between two strings
103
- * @param a First string
104
- * @param b Second string
105
- * @returns Similarity ratio (0-1)
106
- */
107
- export function getSimilarityRatio(a, b) {
108
- const maxLength = Math.max(a.length, b.length);
109
- if (maxLength === 0)
110
- return 1; // Both strings are empty
111
- const levenshteinDistance = distance(a, b);
112
- return 1 - (levenshteinDistance / maxLength);
68
+ /** Same telemetry events the search used to emit inline, now sent from the main thread. */
69
+ function captureFuzzySearchMetrics(metrics) {
70
+ capture('fuzzy_search_recursive_metrics', metrics.recursive);
71
+ if (metrics.iterative) {
72
+ capture('fuzzy_search_iterative_metrics', metrics.iterative);
73
+ }
113
74
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure fuzzy-search core, kept free of app imports on purpose: it runs inside
3
+ * the worker thread spawned by runFuzzySearchInWorker (fuzzySearch.ts), and
4
+ * anything imported here is loaded per worker. Telemetry is returned as data
5
+ * and captured on the main thread, which has the real client identity.
6
+ */
7
+ export interface FuzzyMatch {
8
+ start: number;
9
+ end: number;
10
+ value: string;
11
+ distance: number;
12
+ }
13
+ export interface FuzzySearchMetrics {
14
+ recursive: {
15
+ execution_time_ms: number;
16
+ text_length: number;
17
+ query_length: number;
18
+ result_distance: number;
19
+ };
20
+ iterative: {
21
+ execution_time_ms: number;
22
+ iterations: number;
23
+ segment_length: number;
24
+ query_length: number;
25
+ final_distance: number;
26
+ } | null;
27
+ }
28
+ /**
29
+ * Runs a full fuzzy search and returns the match together with the timing
30
+ * metrics that used to be captured inline.
31
+ */
32
+ export declare function runFuzzySearch(text: string, query: string): {
33
+ result: FuzzyMatch;
34
+ metrics: FuzzySearchMetrics;
35
+ };
36
+ /**
37
+ * Recursively finds the closest match to a query string within text using fuzzy matching
38
+ * @param text The text to search within
39
+ * @param query The query string to find
40
+ * @param start Start index in the text (default: 0)
41
+ * @param end End index in the text (default: text.length)
42
+ * @param parentDistance Best distance found so far (default: Infinity)
43
+ * @returns Object with start and end indices, matched value, and Levenshtein distance
44
+ */
45
+ export declare function recursiveFuzzyIndexOf(text: string, query: string, start?: number, end?: number | null, parentDistance?: number): FuzzyMatch;
46
+ /**
47
+ * Calculates the similarity ratio between two strings
48
+ * @param a First string
49
+ * @param b Second string
50
+ * @returns Similarity ratio (0-1)
51
+ */
52
+ export declare function getSimilarityRatio(a: string, b: string): number;
@@ -0,0 +1,125 @@
1
+ import { distance } from 'fastest-levenshtein';
2
+ // Set by iterativeReduction during a search (exactly one terminal call per
3
+ // search) and collected by runFuzzySearch. Single-threaded per context, so a
4
+ // module-level slot is safe.
5
+ let lastIterativeMetrics = null;
6
+ /**
7
+ * Runs a full fuzzy search and returns the match together with the timing
8
+ * metrics that used to be captured inline.
9
+ */
10
+ export function runFuzzySearch(text, query) {
11
+ const startTime = performance.now();
12
+ lastIterativeMetrics = null;
13
+ const result = recursiveFuzzyIndexOf(text, query);
14
+ return {
15
+ result,
16
+ metrics: {
17
+ recursive: {
18
+ execution_time_ms: performance.now() - startTime,
19
+ text_length: text.length,
20
+ query_length: query.length,
21
+ result_distance: result.distance
22
+ },
23
+ iterative: lastIterativeMetrics
24
+ }
25
+ };
26
+ }
27
+ /**
28
+ * Recursively finds the closest match to a query string within text using fuzzy matching
29
+ * @param text The text to search within
30
+ * @param query The query string to find
31
+ * @param start Start index in the text (default: 0)
32
+ * @param end End index in the text (default: text.length)
33
+ * @param parentDistance Best distance found so far (default: Infinity)
34
+ * @returns Object with start and end indices, matched value, and Levenshtein distance
35
+ */
36
+ export function recursiveFuzzyIndexOf(text, query, start = 0, end = null, parentDistance = Infinity) {
37
+ if (end === null)
38
+ end = text.length;
39
+ // For small text segments, use iterative approach
40
+ if (end - start <= 2 * query.length) {
41
+ return iterativeReduction(text, query, start, end, parentDistance);
42
+ }
43
+ let midPoint = start + Math.floor((end - start) / 2);
44
+ let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps
45
+ let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps
46
+ // Calculate distance for current segments
47
+ let leftDistance = distance(text.substring(start, leftEnd), query);
48
+ let rightDistance = distance(text.substring(rightStart, end), query);
49
+ let bestDistance = Math.min(leftDistance, parentDistance, rightDistance);
50
+ // If parent distance is already the best, use iterative approach
51
+ if (parentDistance === bestDistance) {
52
+ return iterativeReduction(text, query, start, end, parentDistance);
53
+ }
54
+ // Recursively search the better half
55
+ if (leftDistance < rightDistance) {
56
+ return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance);
57
+ }
58
+ else {
59
+ return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance);
60
+ }
61
+ }
62
+ /**
63
+ * Iteratively refines the best match by reducing the search area
64
+ * @param text The text to search within
65
+ * @param query The query string to find
66
+ * @param start Start index in the text
67
+ * @param end End index in the text
68
+ * @param parentDistance Best distance found so far
69
+ * @returns Object with start and end indices, matched value, and Levenshtein distance
70
+ */
71
+ function iterativeReduction(text, query, start, end, parentDistance) {
72
+ const startTime = performance.now();
73
+ let iterations = 0;
74
+ // Seed with the measured distance of this slice. For recursive callers
75
+ // this equals parentDistance (the parent measured exactly this slice), but
76
+ // a top-level call on text <= 2x query length arrives with Infinity, which
77
+ // made the first shrink unconditional and a position-0 match unreachable.
78
+ let bestDistance = distance(text.substring(start, end), query);
79
+ let bestStart = start;
80
+ let bestEnd = end;
81
+ // Improve start position
82
+ let nextDistance = distance(text.substring(bestStart + 1, bestEnd), query);
83
+ while (nextDistance < bestDistance) {
84
+ bestDistance = nextDistance;
85
+ bestStart++;
86
+ const smallerString = text.substring(bestStart + 1, bestEnd);
87
+ nextDistance = distance(smallerString, query);
88
+ iterations++;
89
+ }
90
+ // Improve end position
91
+ nextDistance = distance(text.substring(bestStart, bestEnd - 1), query);
92
+ while (nextDistance < bestDistance) {
93
+ bestDistance = nextDistance;
94
+ bestEnd--;
95
+ const smallerString = text.substring(bestStart, bestEnd - 1);
96
+ nextDistance = distance(smallerString, query);
97
+ iterations++;
98
+ }
99
+ lastIterativeMetrics = {
100
+ execution_time_ms: performance.now() - startTime,
101
+ iterations: iterations,
102
+ segment_length: end - start,
103
+ query_length: query.length,
104
+ final_distance: bestDistance
105
+ };
106
+ return {
107
+ start: bestStart,
108
+ end: bestEnd,
109
+ value: text.substring(bestStart, bestEnd),
110
+ distance: bestDistance
111
+ };
112
+ }
113
+ /**
114
+ * Calculates the similarity ratio between two strings
115
+ * @param a First string
116
+ * @param b Second string
117
+ * @returns Similarity ratio (0-1)
118
+ */
119
+ export function getSimilarityRatio(a, b) {
120
+ const maxLength = Math.max(a.length, b.length);
121
+ if (maxLength === 0)
122
+ return 1; // Both strings are empty
123
+ const levenshteinDistance = distance(a, b);
124
+ return 1 - (levenshteinDistance / maxLength);
125
+ }
@@ -1,4 +1,4 @@
1
- import { terminalManager } from '../terminal-manager.js';
1
+ import { terminalManager, MAX_BUFFERED_OUTPUT_CHARS } from '../terminal-manager.js';
2
2
  import { commandManager } from '../command-manager.js';
3
3
  import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, InteractWithProcessArgsSchema, ForceTerminateArgsSchema } from './schemas.js';
4
4
  import { capture } from "../utils/capture.js";
@@ -293,6 +293,13 @@ export async function readProcessOutput(args) {
293
293
  // Absolute position read
294
294
  statusMessage = `[Reading ${result.readCount} lines from line ${result.readFrom} (total: ${result.totalLines} lines, ${result.remaining} remaining)]`;
295
295
  }
296
+ // Surface buffer-cap eviction so the model knows the retained output is not
297
+ // the full output and that line numbers shifted (matches the truncation
298
+ // markers used by other tools).
299
+ if (result.evictedLines && result.evictedLines > 0) {
300
+ const capMB = Math.round(MAX_BUFFERED_OUTPUT_CHARS / 1024 / 1024);
301
+ statusMessage += `\n[WARNING: output exceeded the ${capMB}MB buffer cap; the ${result.evictedLines} earliest lines were evicted and cannot be read. Line numbers and totals refer to the retained buffer only]`;
302
+ }
296
303
  // Add process state info
297
304
  let processStateMessage = '';
298
305
  if (result.isComplete) {
@@ -1,5 +1,17 @@
1
1
  import type { PageRange } from './lib/pdf2md.js';
2
2
  import { PdfParseResult } from './lib/pdf2md.js';
3
+ interface CachedPuppeteerChrome {
4
+ executablePath: string;
5
+ }
6
+ /**
7
+ * Find Chrome in puppeteer's cache directory
8
+ * Returns the executable path if found, undefined otherwise
9
+ */
10
+ export declare function findPuppeteerChrome(cacheDir?: string): CachedPuppeteerChrome | undefined;
11
+ /**
12
+ * Remove stale Puppeteer Chrome builds while preserving the active build.
13
+ */
14
+ export declare function pruneOldPuppeteerChromeBuilds(activeExecutablePath: string, cacheDir?: string): Promise<void>;
3
15
  /**
4
16
  * Preemptively ensure Chrome is available for PDF generation.
5
17
  * Call this at server startup to trigger download in background if needed.
@@ -11,3 +23,4 @@ export declare function ensureChromeAvailable(): void;
11
23
  */
12
24
  export declare function parsePdfToMarkdown(source: string, pageNumbers?: number[] | PageRange): Promise<PdfParseResult>;
13
25
  export declare function parseMarkdownToPdf(markdown: string, options?: any): Promise<Buffer>;
26
+ export {};
@@ -1,48 +1,76 @@
1
1
  import fs from 'fs/promises';
2
- import { existsSync } from 'fs';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
4
  import { mdToPdf } from 'md-to-pdf';
6
5
  import { pdf2md } from './lib/pdf2md.js';
6
+ import { CONFIG_FILE } from '../../config.js';
7
7
  const isUrl = (source) => source.startsWith('http://') || source.startsWith('https://');
8
8
  // Cached Chrome path to avoid repeated lookups
9
9
  let cachedChromePath = null; // null = not checked yet
10
10
  let chromeCheckPromise = null;
11
11
  /**
12
- * Get the puppeteer cache directory
12
+ * Get Desktop Commander's private Puppeteer cache directory.
13
13
  */
14
14
  function getPuppeteerCacheDir() {
15
- return join(homedir(), '.cache', 'puppeteer');
15
+ return join(dirname(CONFIG_FILE), 'puppeteer-cache');
16
+ }
17
+ /**
18
+ * Get the cache path where Puppeteer stores Chrome for Testing builds.
19
+ */
20
+ function getPuppeteerChromeDir(cacheDir = getPuppeteerCacheDir()) {
21
+ return join(cacheDir, 'chrome');
22
+ }
23
+ /**
24
+ * Find the platform-specific executable within a cached Chrome build directory.
25
+ */
26
+ function getChromeExecutablePath(chromeDir, version) {
27
+ const chromePath = process.platform === 'win32'
28
+ ? join(chromeDir, version, 'chrome-win64', 'chrome.exe')
29
+ : process.platform === 'darwin'
30
+ ? join(chromeDir, version, 'chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing')
31
+ : join(chromeDir, version, 'chrome-linux64', 'chrome');
32
+ if (existsSync(chromePath)) {
33
+ return chromePath;
34
+ }
35
+ // Also check for arm64 mac
36
+ if (process.platform === 'darwin') {
37
+ const armPath = join(chromeDir, version, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing');
38
+ if (existsSync(armPath)) {
39
+ return armPath;
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ /**
45
+ * Resolve the cached Chrome build directory that owns an executable path.
46
+ */
47
+ function getCachedChromeBuildDir(chromeDir, executablePath) {
48
+ const relativePath = relative(chromeDir, executablePath);
49
+ if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
50
+ return undefined;
51
+ }
52
+ const [buildDir] = relativePath.split(sep);
53
+ return buildDir ? join(chromeDir, buildDir) : undefined;
16
54
  }
17
55
  /**
18
56
  * Find Chrome in puppeteer's cache directory
19
57
  * Returns the executable path if found, undefined otherwise
20
58
  */
21
- function findPuppeteerChrome() {
22
- const cacheDir = getPuppeteerCacheDir();
23
- const chromeDir = join(cacheDir, 'chrome');
59
+ export function findPuppeteerChrome(cacheDir = getPuppeteerCacheDir()) {
60
+ const chromeDir = getPuppeteerChromeDir(cacheDir);
24
61
  if (!existsSync(chromeDir)) {
25
62
  return undefined;
26
63
  }
27
64
  try {
28
65
  // Look for chrome directories (e.g., win64-143.0.7499.169)
29
- const { readdirSync } = require('fs');
30
- const versions = readdirSync(chromeDir);
66
+ const versions = readdirSync(chromeDir, { withFileTypes: true })
67
+ .filter(entry => entry.isDirectory())
68
+ .map(entry => entry.name)
69
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
31
70
  for (const version of versions) {
32
- const chromePath = process.platform === 'win32'
33
- ? join(chromeDir, version, 'chrome-win64', 'chrome.exe')
34
- : process.platform === 'darwin'
35
- ? join(chromeDir, version, 'chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing')
36
- : join(chromeDir, version, 'chrome-linux64', 'chrome');
37
- if (existsSync(chromePath)) {
38
- return chromePath;
39
- }
40
- // Also check for arm64 mac
41
- if (process.platform === 'darwin') {
42
- const armPath = join(chromeDir, version, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing');
43
- if (existsSync(armPath)) {
44
- return armPath;
45
- }
71
+ const executablePath = getChromeExecutablePath(chromeDir, version);
72
+ if (executablePath) {
73
+ return { executablePath };
46
74
  }
47
75
  }
48
76
  }
@@ -51,6 +79,37 @@ function findPuppeteerChrome() {
51
79
  }
52
80
  return undefined;
53
81
  }
82
+ /**
83
+ * Remove stale Puppeteer Chrome builds while preserving the active build.
84
+ */
85
+ export async function pruneOldPuppeteerChromeBuilds(activeExecutablePath, cacheDir = getPuppeteerCacheDir()) {
86
+ const chromeDir = getPuppeteerChromeDir(cacheDir);
87
+ const activeBuildDir = getCachedChromeBuildDir(chromeDir, activeExecutablePath);
88
+ if (!activeBuildDir) {
89
+ return;
90
+ }
91
+ let entries;
92
+ try {
93
+ entries = await fs.readdir(chromeDir, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ await Promise.all(entries
99
+ .filter(entry => entry.isDirectory())
100
+ .map(async (entry) => {
101
+ const buildDir = join(chromeDir, entry.name);
102
+ if (resolve(buildDir) === resolve(activeBuildDir)) {
103
+ return;
104
+ }
105
+ try {
106
+ await fs.rm(buildDir, { recursive: true, force: true });
107
+ }
108
+ catch (error) {
109
+ console.error(`Failed to remove old Chrome cache build at ${buildDir}:`, error);
110
+ }
111
+ }));
112
+ }
54
113
  /**
55
114
  * Find system-installed Chrome/Chromium browser
56
115
  * Returns the executable path if found, undefined otherwise
@@ -91,6 +150,7 @@ async function installChrome() {
91
150
  const platform = detectBrowserPlatform();
92
151
  const buildId = await resolveBuildId(Browser.CHROME, platform, 'stable');
93
152
  console.error('Downloading Chrome for PDF generation (this may take a few minutes)...');
153
+ await fs.mkdir(cacheDir, { recursive: true });
94
154
  const installedBrowser = await install({
95
155
  browser: Browser.CHROME,
96
156
  buildId,
@@ -101,7 +161,9 @@ async function installChrome() {
101
161
  },
102
162
  });
103
163
  console.error('\nChrome download complete.');
104
- return installedBrowser.executablePath;
164
+ return {
165
+ executablePath: installedBrowser.executablePath,
166
+ };
105
167
  }
106
168
  /**
107
169
  * Find or install Chrome for PDF generation
@@ -122,8 +184,9 @@ async function getChromePath() {
122
184
  // 1. Check puppeteer cache first (exact compatible version)
123
185
  const cachedChrome = findPuppeteerChrome();
124
186
  if (cachedChrome) {
125
- cachedChromePath = cachedChrome;
126
- return cachedChrome;
187
+ await pruneOldPuppeteerChromeBuilds(cachedChrome.executablePath);
188
+ cachedChromePath = cachedChrome.executablePath;
189
+ return cachedChrome.executablePath;
127
190
  }
128
191
  // 2. Check system Chrome
129
192
  const systemChrome = findSystemChrome();
@@ -134,8 +197,9 @@ async function getChromePath() {
134
197
  // 3. Install Chrome as last resort
135
198
  try {
136
199
  const installedChrome = await installChrome();
137
- cachedChromePath = installedChrome;
138
- return installedChrome;
200
+ await pruneOldPuppeteerChromeBuilds(installedChrome.executablePath);
201
+ cachedChromePath = installedChrome.executablePath;
202
+ return installedChrome.executablePath;
139
203
  }
140
204
  catch (error) {
141
205
  console.error('Failed to install Chrome:', error);