From 445c3c5ae3fca98168a3af0e28609d31b3ae6f03 Mon Sep 17 00:00:00 2001 From: priyadarshnisundararajan <29859979+priyadarshnisundararajan@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:23:03 -0700 Subject: [PATCH] fix: skip broken symlinks during pack instead of failing with ENOENT --- src/node/files.ts | 33 ++++++++++++++++++++++------ test/cli.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/node/files.ts b/src/node/files.ts index 36e64f82..e5e00a28 100644 --- a/src/node/files.ts +++ b/src/node/files.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "fs"; +import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; import { join, relative, sep } from "path"; @@ -94,9 +94,19 @@ export function getAllFiles( continue; } - const stat = statSync(filePath); + const stat = lstatSync(filePath); - if (stat.isDirectory()) { + if (stat.isSymbolicLink() && !existsSync(filePath)) { + // Broken (dangling) symlink: statSync would throw ENOENT. Skip it and + // keep packing instead of aborting the whole bundle. + console.warn(`Warning: Skipping broken symlink: ${relativePath}`); + continue; + } + + // Valid symlinks are still followed, preserving the previous behavior. + const targetStat = stat.isSymbolicLink() ? statSync(filePath) : stat; + + if (targetStat.isDirectory()) { getAllFiles(filePath, baseDir, fileList, additionalPatterns); } else { // Use forward slashes in zip file paths @@ -137,9 +147,20 @@ export function getAllFilesWithCount( continue; } - const stat = statSync(filePath); + const stat = lstatSync(filePath); + + if (stat.isSymbolicLink() && !existsSync(filePath)) { + // Broken (dangling) symlink: statSync would throw ENOENT. Skip it and + // keep packing instead of aborting the whole bundle. + console.warn(`Warning: Skipping broken symlink: ${relativePath}`); + ignoredCount++; + continue; + } + + // Valid symlinks are still followed, preserving the previous behavior. + const targetStat = stat.isSymbolicLink() ? statSync(filePath) : stat; - if (stat.isDirectory()) { + if (targetStat.isDirectory()) { const result = getAllFilesWithCount( filePath, baseDir, @@ -153,7 +174,7 @@ export function getAllFilesWithCount( const zipPath = relativePath.split(sep).join("/"); fileList[zipPath] = { data: readFileSync(filePath), - mode: stat.mode, + mode: targetStat.mode, }; } } diff --git a/test/cli.test.ts b/test/cli.test.ts index b74e9ac3..53e73c20 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -221,6 +221,61 @@ describe("DXT CLI", () => { expect(fs.existsSync(packedFilePath)).toBe(true); }); + it("should skip a broken symlink instead of failing with ENOENT", () => { + const symlinkDir = join(__dirname, "temp-symlink-test"); + const symlinkPackedPath = join(__dirname, "test-symlink.dxt"); + const symlinkUnpackedDir = join(__dirname, "temp-symlink-unpack"); + + try { + // Fixture directory containing a broken (dangling) symlink + fs.mkdirSync(symlinkDir, { recursive: true }); + fs.writeFileSync( + join(symlinkDir, "manifest.json"), + JSON.stringify({ + manifest_version: DEFAULT_MANIFEST_VERSION, + name: "Test Symlink", + version: "1.0.0", + description: "Test broken symlink handling", + author: { + name: "MCPB", + }, + server: { + type: "node", + entry_point: "server/index.js", + mcp_config: { + command: "node", + }, + }, + }), + ); + fs.mkdirSync(join(symlinkDir, "server"), { recursive: true }); + fs.writeFileSync(join(symlinkDir, "server", "index.js"), "// fixture"); + fs.symlinkSync("./nowhere", join(symlinkDir, "dangling")); + + // pack must succeed (exit 0) and write the bundle + execSync(`node ${cliPath} pack ${symlinkDir} ${symlinkPackedPath}`, { + encoding: "utf-8", + }); + expect(fs.existsSync(symlinkPackedPath)).toBe(true); + + // the broken symlink must not end up in the bundle + execSync( + `node ${cliPath} unpack ${symlinkPackedPath} ${symlinkUnpackedDir}`, + { encoding: "utf-8" }, + ); + expect(fs.existsSync(join(symlinkUnpackedDir, "manifest.json"))).toBe( + true, + ); + expect(fs.existsSync(join(symlinkUnpackedDir, "dangling"))).toBe(false); + } finally { + fs.rmSync(symlinkDir, { recursive: true, force: true }); + fs.rmSync(symlinkUnpackedDir, { recursive: true, force: true }); + if (fs.existsSync(symlinkPackedPath)) { + fs.unlinkSync(symlinkPackedPath); + } + } + }); + it("should unpack an extension", () => { execSync(`node ${cliPath} unpack ${packedFilePath} ${unpackedDir}`, { encoding: "utf-8",