Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/handlers/filesystem-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
/**
* Handle read_multiple_files command
*/
export async function handleReadMultipleFiles(args: unknown): Promise<ServerResult> {
export async function handleReadMultipleFiles(args: unknown, stillLoadingError?: (filePath: string) => string | undefined): Promise<ServerResult> {
const parsed = ReadMultipleFilesArgsSchema.parse(args);
const fileResults = await readMultipleFiles(parsed.paths);
const fileResults = await readMultipleFiles(parsed.paths, stillLoadingError);

// Create a text summary of all files
const textSummary = fileResults.map(result => {
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { logToStderr, logger } from './utils/logger.js';
import { exitProcess } from './utils/exit-process.js';
import { runRemote } from './npm-scripts/remote.js';
import { ensureChromeAvailable } from './tools/pdf/markdown.js';
import { preloadFileSupport } from './utils/files/index.js';

// Store messages to defer until after initialization
const deferredMessages: Array<{ level: string, message: string }> = [];
Expand Down Expand Up @@ -136,6 +137,10 @@ async function runServer() {

// Preemptively check/download Chrome for PDF generation (runs in background)
ensureChromeAvailable();

// Load the Excel, DOCX and PDF packages, one at a time, now that initialize
// has been answered; until then the calls that need them answer at once
preloadFileSupport();
};

await server.connect(transport);
Expand Down
15 changes: 11 additions & 4 deletions src/search-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { capture } from './utils/capture.js';
import { logger } from './utils/logger.js';
import { getRipgrepPath } from './utils/ripgrep-resolver.js';
import { isExcelFile } from './utils/files/index.js';
import PizZip from 'pizzip';
import { exceljsPackage } from './utils/files/excel.js';
import { pizzipPackage } from './utils/files/docx.js';

export interface SearchResult {
context?: boolean; // A line around a match (contextLines), not a match
Expand Down Expand Up @@ -576,14 +577,16 @@ function characterClassEnd(glob: string, start: number): number {
excelFiles = this.filterOfficeFiles(excelFiles, filePattern, rootPath);
}

// Dynamically import ExcelJS to search all sheets
const ExcelJS = await import('exceljs');
if (excelFiles.length === 0) return;
// ExcelJS, to search all sheets: loaded here if the server hasn't yet
// (a search runs in the background, so it isn't refused while it loads)
const ExcelJS = exceljsPackage.load();

for (const filePath of excelFiles) {
if (sink.isStopped()) break;

try {
const workbook = new ExcelJS.default.Workbook();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);

// Search ALL sheets in the workbook (row-wise for speed and cross-column matching)
Expand Down Expand Up @@ -773,6 +776,10 @@ function characterClassEnd(glob: string, start: number): number {
docxFiles = this.filterOfficeFiles(docxFiles, filePattern, rootPath);
}

if (docxFiles.length === 0) return;
// PizZip, to open the DOCX files: loaded here if the server hasn't yet
const PizZip = pizzipPackage.load();

for (const filePath of docxFiles) {
if (sink.isStopped()) break;

Expand Down
30 changes: 24 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
import * as handlers from './handlers/index.js';
import { ServerResult } from './types.js';
import { withoutInternalFacts } from './utils/internal-facts.js';
import { stillLoadingError, type FileAction } from './utils/files/index.js';

/**
* A tool call on an Excel, DOCX or PDF file whose package is still loading
* after initialize answers at once with this error (utils/files/factory.ts)
* instead of loading it inside the call. Not sent to telemetry, unlike other
* errors: it names the file. read_multiple_files answers per file;
* get_file_info waits for the load, or loads it itself, and so does a search.
*/
function stillLoading(filePath: unknown, action: FileAction, options?: { isPdf?: boolean; isUrl?: boolean }): ServerResult | undefined {
const error = typeof filePath === 'string' ? stillLoadingError(filePath, action, options) : undefined;
return error === undefined ? undefined : { content: [{ type: 'text', text: `Error: ${error}` }], isError: true };
}

server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {
const args = request.params.arguments;
Expand Down Expand Up @@ -1445,20 +1458,25 @@ async function handleCallToolRequest(request: CallToolRequest): Promise<ServerRe

// Filesystem tools
case "read_file":
result = await handlers.handleReadFile(args);
result = stillLoading(args?.path, 'read', { isUrl: args?.isUrl === true }) ?? await handlers.handleReadFile(args);
break;

case "read_multiple_files":
result = await handlers.handleReadMultipleFiles(args);
result = await handlers.handleReadMultipleFiles(args, (filePath) => stillLoadingError(filePath, 'read'));
break;

case "write_file":
result = await handlers.handleWriteFile(args);
result = stillLoading(args?.path, 'write') ?? await handlers.handleWriteFile(args);
break;

case "write_pdf":
result = await handlers.handleWritePdf(args);
case "write_pdf": {
// Markdown (a new PDF) or page edits, read as the tool reads them;
// arguments the tool refuses get its own error
const parsed = WritePdfArgsSchema.safeParse(args);
const action = parsed.success && typeof parsed.data.content === 'string' ? 'write' : 'edit';
result = (parsed.success ? stillLoading(parsed.data.path, action, { isPdf: true }) : undefined) ?? await handlers.handleWritePdf(args);
break;
}

case "create_directory":
result = await handlers.handleCreateDirectory(args);
Expand Down Expand Up @@ -1493,7 +1511,7 @@ async function handleCallToolRequest(request: CallToolRequest): Promise<ServerRe
break;

case "edit_block":
result = await handlers.handleEditBlock(args);
result = stillLoading(args?.file_path, 'edit') ?? await handlers.handleEditBlock(args);
break;

default:
Expand Down
11 changes: 10 additions & 1 deletion src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,9 +750,18 @@ export interface MultiFileResult {
payload?: FileResultPayloads;
}

export async function readMultipleFiles(paths: string[]): Promise<MultiFileResult[]> {
/**
* Reads each file; a failure is that file's `error`. `stillLoadingError` (the
* server's) gives the error for a file whose package is still loading, which
* is then not read (utils/files/factory.ts).
*/
export async function readMultipleFiles(paths: string[], stillLoadingError?: (filePath: string) => string | undefined): Promise<MultiFileResult[]> {
return Promise.all(
paths.map(async (filePath: string) => {
const stillLoading = stillLoadingError?.(filePath);
if (stillLoading) {
return { path: filePath, error: stillLoading };
}
try {
const validPath = await validatePath(filePath);
const fileResult = await readFile(validPath);
Expand Down
6 changes: 5 additions & 1 deletion src/tools/pdf/extract-images.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getDocumentProxy, extractImages } from 'unpdf';
import { LazyPackage } from '../../utils/lazy-package.js';

export interface ImageInfo {
/** Object ID within PDF */
Expand Down Expand Up @@ -29,6 +29,9 @@ export interface ImageCompressionOptions {
maxDimension?: number;
}

// import(): unpdf is an ES module only. If it fails, Node keeps it failed until a restart.
export const unpdfPackage = new LazyPackage('unpdf', 'PDF reading support', () => import('unpdf'));

/**
* Optimized image extraction from PDF using unpdf's built-in extractImages method
* @param pdfBuffer PDF file as Uint8Array
Expand All @@ -41,6 +44,7 @@ export async function extractImagesFromPdf(
pageNumbers?: number[],
compressionOptions: ImageCompressionOptions = {}
): Promise<Record<number, ImageInfo[]>> {
const { getDocumentProxy, extractImages } = await unpdfPackage.load();
const pdfDocument = await getDocumentProxy(pdfBuffer);

const pagesToProcess = pageNumbers || Array.from({ length: pdfDocument.numPages }, (_, i) => i + 1);
Expand Down
7 changes: 4 additions & 3 deletions src/tools/pdf/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export { editPdf, insertRenderOptions } from './manipulations.js';
export { editPdf, insertRenderOptions, pdfLibPackage } from './manipulations.js';
export type { PdfOperations, PdfInsertOperation, PdfDeleteOperation } from './manipulations.js';
export { parsePdfToMarkdown, parseMarkdownToPdf, resolveRender } from './markdown.js';
export { parsePdfToMarkdown, parseMarkdownToPdf, resolveRender, mdToPdfPackage } from './markdown.js';
export type { IgnoredRenderOption } from './markdown.js';
export { pdf2mdPackage } from './lib/pdf2md.js';
export type { PdfMetadata, PdfPageItem } from './lib/pdf2md.js';
export { extractImagesFromPdf } from './extract-images.js';
export { extractImagesFromPdf, unpdfPackage } from './extract-images.js';
export type { ImageInfo, PageImages } from './extract-images.js';

12 changes: 9 additions & 3 deletions src/tools/pdf/lib/pdf2md.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { createRequire } from 'module';

import { LazyPackage } from '../../../utils/lazy-package.js';
import { generatePageNumbers } from '../utils.js';
import { extractImagesFromPdf, ImageInfo } from '../extract-images.js';
const require = createRequire(import.meta.url);

const { parse } = require('@opendocsg/pdf2md/lib/util/pdf');
const { makeTransformations, transform } = require('@opendocsg/pdf2md/lib/util/transformations');
/** What @opendocsg/pdf2md's parse() returns: its modules are loaded untyped, with require() */
type ParseResult = any;

type ParseResult = ReturnType<typeof parse>;
export const pdf2mdPackage = new LazyPackage('@opendocsg/pdf2md', 'PDF reading support', () => {
const { parse } = require('@opendocsg/pdf2md/lib/util/pdf');
const { makeTransformations, transform } = require('@opendocsg/pdf2md/lib/util/transformations');
return { parse, makeTransformations, transform };
});


/**
Expand Down Expand Up @@ -69,6 +74,7 @@ export type PageRange = {
* @returns A Promise that resolves to a PdfParseResult object containing the parsed data.
*/
export async function pdf2md(pdfBuffer: Uint8Array, pageNumbers: number[] | PageRange = []): Promise<PdfParseResult> {
const { parse, makeTransformations, transform } = pdf2mdPackage.load();

const result = await parse(pdfBuffer);
const { fonts, pages, pdfDocument } = result;
Expand Down
5 changes: 4 additions & 1 deletion src/tools/pdf/manipulations.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import fs from 'fs/promises';
import { createRequire } from 'module';
import type { PDFDocument as PDFDocumentType, PDFPage } from 'pdf-lib';
import { LazyPackage } from '../../utils/lazy-package.js';
import { normalizePageIndexes } from './utils.js';
import { parseMarkdownToPdf } from './markdown.js';
import type { PdfInsertOperationSchema, PdfDeleteOperationSchema, PdfOperationSchema } from '../schemas.js';
import { z } from 'zod';

// Use createRequire to load pdf-lib as CJS (works around Node 25 ESM resolution issues)
const require = createRequire(import.meta.url);
const { PDFDocument } = require('pdf-lib') as { PDFDocument: typeof PDFDocumentType };

// Infer TypeScript types from Zod schemas for consistency
type PdfInsertOperation = z.infer<typeof PdfInsertOperationSchema>;
Expand All @@ -17,7 +17,10 @@ type PdfOperations = z.infer<typeof PdfOperationSchema>;

export type { PdfOperations, PdfInsertOperation, PdfDeleteOperation };

export const pdfLibPackage = new LazyPackage('pdf-lib', 'PDF editing support', (): { PDFDocument: typeof PDFDocumentType } => require('pdf-lib'));

async function loadPdfDocumentFromBuffer(filePathOrBuffer: string | Buffer | Uint8Array): Promise<PDFDocumentType> {
const { PDFDocument } = pdfLibPackage.load();
const buffer = typeof filePathOrBuffer === 'string' ? await fs.readFile(filePathOrBuffer) : filePathOrBuffer;
const pdfBytes = new Uint8Array(buffer);
return await PDFDocument.load(pdfBytes);
Expand Down
35 changes: 22 additions & 13 deletions src/tools/pdf/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { createRequire } from 'module';
import { tmpdir, userInfo } from 'os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
import type { Browser, LaunchOptions, PuppeteerNode } from 'puppeteer';
import { convertMdToPdf } from 'md-to-pdf/dist/lib/md-to-pdf.js';
import { defaultConfig, type Config as MdToPdfConfig } from 'md-to-pdf/dist/lib/config.js';
import type { Config as MdToPdfConfig } from 'md-to-pdf/dist/lib/config.js';
import type { PageRange } from './lib/pdf2md.js';
import { PdfParseResult, pdf2md } from './lib/pdf2md.js';
import { CONFIG_FILE } from '../../config.js';
import { LazyPackage } from '../../utils/lazy-package.js';

const isUrl = (source: string): boolean =>
source.startsWith('http://') || source.startsWith('https://');
Expand All @@ -34,13 +34,20 @@ const CHROME_EXIT_WAIT_MS = 10_000;
/** Cookie a render's Chrome sends to its web server; nothing else gets in */
const RENDER_COOKIE_NAME = 'desktop-commander-pdf-render';

// md-to-pdf's own Puppeteer, file server and front matter parser, loaded the
const require = createRequire(import.meta.url);

// md-to-pdf, with its own Puppeteer, file server and front matter parser, loaded the
// way md-to-pdf loads them (they are its dependencies, not Desktop Commander's)
const requireFromMdToPdf = createRequire(createRequire(import.meta.url).resolve('md-to-pdf'));
const puppeteer: PuppeteerNode = requireFromMdToPdf('puppeteer');
const serveHandler: (request: IncomingMessage, response: ServerResponse, config: { public: string; directoryListing: boolean; cleanUrls: boolean }) => Promise<void> =
requireFromMdToPdf('serve-handler');
const grayMatter: (input: string, options: unknown) => { content: string; data: unknown } = requireFromMdToPdf('gray-matter');
export const mdToPdfPackage = new LazyPackage('md-to-pdf', 'PDF writing support', () => {
const requireFromMdToPdf = createRequire(require.resolve('md-to-pdf'));
const { convertMdToPdf }: typeof import('md-to-pdf/dist/lib/md-to-pdf.js') = require('md-to-pdf/dist/lib/md-to-pdf.js');
const { defaultConfig }: typeof import('md-to-pdf/dist/lib/config.js') = require('md-to-pdf/dist/lib/config.js');
const puppeteer: PuppeteerNode = requireFromMdToPdf('puppeteer');
const serveHandler: (request: IncomingMessage, response: ServerResponse, config: { public: string; directoryListing: boolean; cleanUrls: boolean }) => Promise<void> =
requireFromMdToPdf('serve-handler');
const grayMatter: (input: string, options: unknown) => { content: string; data: unknown } = requireFromMdToPdf('gray-matter');
return { convertMdToPdf, defaultConfig, puppeteer, serveHandler, grayMatter };
});

const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
Expand Down Expand Up @@ -100,9 +107,6 @@ interface ResolvedRender {
ignoredOptions: IgnoredRenderOption[];
}

/** md-to-pdf's switch-off of gray-matter's JavaScript engine, which evaluates the header's code */
const DISABLED_JS_ENGINE = (defaultConfig.gray_matter_options as { engines: Record<string, unknown> }).engines.javascript;

/**
* The caller's gray_matter_options over md-to-pdf's defaults, with the
* JavaScript engine off whatever the caller says: a caller's settings (even
Expand All @@ -112,12 +116,15 @@ const DISABLED_JS_ENGINE = (defaultConfig.gray_matter_options as { engines: Reco
* both names are switched off.
*/
function safeGrayMatterOptions(callerOptions: unknown): Record<string, unknown> {
const { defaultConfig } = mdToPdfPackage.load();
// md-to-pdf's switch-off of gray-matter's JavaScript engine, which evaluates the header's code
const disabledJsEngine = (defaultConfig.gray_matter_options as { engines: Record<string, unknown> }).engines.javascript;
const caller = isPlainObject(callerOptions) ? callerOptions : {};
const callerEngines = isPlainObject(caller.engines) ? caller.engines : {};
return {
...defaultConfig.gray_matter_options,
...caller,
engines: { ...callerEngines, js: DISABLED_JS_ENGINE, javascript: DISABLED_JS_ENGINE },
engines: { ...callerEngines, js: disabledJsEngine, javascript: disabledJsEngine },
};
}

Expand All @@ -130,6 +137,7 @@ function safeGrayMatterOptions(callerOptions: unknown): Record<string, unknown>
* md-to-pdf merging the front matter a second time.
*/
export function resolveRender(markdown: string, options: unknown = {}): ResolvedRender {
const { grayMatter } = mdToPdfPackage.load();
const fromOptions = isPlainObject(options) ? options : {};
// Parse the front matter the way md-to-pdf would, with the caller's
// gray_matter_options, but never with gray-matter's JavaScript engine
Expand Down Expand Up @@ -578,7 +586,7 @@ async function serveAllowedFile(request: IncomingMessage, response: ServerRespon
// serve-handler serves exactly that file (no .html redirects, which would name it from another folder).
// It reads only the request's url and headers; the request itself keeps its URL.
const forFile = { url: `/${encodeURIComponent(basename(file))}`, headers: request.headers } as IncomingMessage;
await serveHandler(forFile, response, { public: dirname(file), directoryListing: false, cleanUrls: false });
await mdToPdfPackage.load().serveHandler(forFile, response, { public: dirname(file), directoryListing: false, cleanUrls: false });
}

/**
Expand Down Expand Up @@ -767,6 +775,7 @@ export async function parseMarkdownToPdf(markdown: string, options: any = {}): P
// The render files as the caller gave them, by the checked paths the render reads
let givenPaths = new Map<string, string>();
try {
const { convertMdToPdf, defaultConfig, puppeteer } = mdToPdfPackage.load();
// The folder the markdown's files are served from must be inside the allowed folders
const { validatePath } = await import('../filesystem.js');
const basedir: string = options.basedir ? await validatePath(options.basedir) : process.cwd();
Expand Down
20 changes: 16 additions & 4 deletions src/utils/files/docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
*/

import fs from 'fs/promises';
import PizZip from 'pizzip';
import { createRequire } from 'module';
import type PizZip from 'pizzip';
import { LazyPackage } from '../lazy-package.js';
import { FileHandler, FileResult, FileInfo, ReadOptions, EditResult } from './base.js';

// ════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -71,8 +73,18 @@ interface DocxZipContents {
xmlParts: Map<string, string>;
}

const require = createRequire(import.meta.url);

export const pizzipPackage = new LazyPackage('pizzip', 'DOCX support', (): typeof PizZip => require('pizzip'));

/** Opens a zip from its bytes, or a new, empty one */
function openZip(data?: Buffer): PizZip {
const PizZipClass = pizzipPackage.load();
return data === undefined ? new PizZipClass() : new PizZipClass(data);
}

function loadDocxZip(buf: Buffer): DocxZipContents {
const zip = new PizZip(buf);
const zip = openZip(buf);
const docFile = zip.file('word/document.xml');
if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml');

Expand Down Expand Up @@ -448,7 +460,7 @@ function escapeXml(text: string): string {
}

function createMinimalDocxZip(documentXml: string): PizZip {
const zip = new PizZip();
const zip = openZip();

zip.file('[Content_Types].xml',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
Expand Down Expand Up @@ -651,7 +663,7 @@ export class DocxFileHandler implements FileHandler {

// Load and pretty-print
const buf = await fs.readFile(path);
const zip = new PizZip(buf);
const zip = openZip(buf);
const docFile = zip.file('word/document.xml');
if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml');

Expand Down
Loading
Loading