Skip to content
Open
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
20 changes: 4 additions & 16 deletions src/cli/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,27 +225,15 @@ export async function packExtension({
// Create zip with preserved file permissions
const zipFiles: Zippable = {};

const isUnix = process.platform !== "win32";
const isBinaryType = manifest.server?.type === "binary";
const binaryEntryPoint = isBinaryType ? manifest.server.entry_point : null;

for (const [filePath, fileData] of Object.entries(files)) {
if (isUnix) {
let mode = fileData.mode & 0o777;
// Ensure binary entry points are always executable in the ZIP,
// regardless of filesystem permissions. Windows-built ZIPs store
// 644 because Windows has no Unix execute bit — CD spawns binary
// entry points directly, so they must have +x to avoid EACCES.
if (binaryEntryPoint && filePath === binaryEntryPoint) {
mode = mode | 0o111;
}
// Set external file attributes to preserve Unix permissions
// The mode needs to be shifted to the upper 16 bits for ZIP format
zipFiles[filePath] = [fileData.data, { os: 3, attrs: mode << 16 }];
} else {
// On Windows, use default ZIP attributes (no Unix permissions)
zipFiles[filePath] = fileData.data;
let mode = fileData.mode & 0o777;
if (binaryEntryPoint && filePath === binaryEntryPoint) {
mode = mode | 0o111;
}
zipFiles[filePath] = [fileData.data, { os: 3, attrs: mode << 16 }];
}

const zipData = zipSync(zipFiles, {
Expand Down
96 changes: 96 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,48 @@ interface ExecSyncError extends Error {
signal: string | null;
}

function readZipMode(zipPath: string, targetPath: string): number | undefined {
const zipBuffer = fs.readFileSync(zipPath);
let eocdOffset = -1;

for (let i = zipBuffer.length - 22; i >= 0; i--) {
if (zipBuffer.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}

if (eocdOffset === -1) {
throw new Error("End of central directory record not found");
}

const centralDirOffset = zipBuffer.readUInt32LE(eocdOffset + 16);
const centralDirEntries = zipBuffer.readUInt16LE(eocdOffset + 8);
let offset = centralDirOffset;

for (let i = 0; i < centralDirEntries; i++) {
if (zipBuffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error(`Invalid central directory record at ${offset}`);
}

const externalAttrs = zipBuffer.readUInt32LE(offset + 38);
const filenameLength = zipBuffer.readUInt16LE(offset + 28);
const extraFieldLength = zipBuffer.readUInt16LE(offset + 30);
const commentLength = zipBuffer.readUInt16LE(offset + 32);
const filename = zipBuffer.toString(
"utf8",
offset + 46,
offset + 46 + filenameLength,
);

if (filename === targetPath) {
return (externalAttrs >> 16) & 0o777;
}

offset += 46 + filenameLength + extraFieldLength + commentLength;
}
}

describe("DXT CLI", () => {
const cliPath = join(__dirname, "../dist/cli/cli.js");
const validManifestPath = join(__dirname, "valid-manifest.json");
Expand Down Expand Up @@ -343,6 +385,60 @@ describe("DXT CLI", () => {
}
});

it("should write Unix permission attributes when packing on any platform", () => {
const tempBinDir = join(__dirname, "temp-binary-zip-attrs-test");
const packedFilePath = join(__dirname, "test-binary-zip-attrs.dxt");

try {
fs.mkdirSync(join(tempBinDir, "server"), { recursive: true });
fs.writeFileSync(
join(tempBinDir, "manifest.json"),
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "Test Binary Zip Attrs",
version: "1.0.0",
description: "A test extension with zip mode attrs",
author: {
name: "MCPB",
},
server: {
type: "binary",
entry_point: "server/myserver",
mcp_config: {
command: "${__dirname}/server/myserver",
},
},
}),
);
fs.writeFileSync(
join(tempBinDir, "server", "myserver"),
"#!/bin/sh\necho binary",
);
fs.writeFileSync(join(tempBinDir, "config.json"), "{}");

execSync(`node ${cliPath} pack ${tempBinDir} ${packedFilePath}`, {
encoding: "utf-8",
});

const entryMode = fs.statSync(
join(tempBinDir, "server", "myserver"),
).mode;
const configMode = fs.statSync(join(tempBinDir, "config.json")).mode;

expect(readZipMode(packedFilePath, "server/myserver")).toBe(
(entryMode & 0o777) | 0o111,
);
expect(readZipMode(packedFilePath, "config.json")).toBe(
configMode & 0o777,
);
} finally {
fs.rmSync(tempBinDir, { recursive: true, force: true });
if (fs.existsSync(packedFilePath)) {
fs.unlinkSync(packedFilePath);
}
}
});

it("should preserve executable file permissions after packing and unpacking", () => {
// Skip this test on Windows since it doesn't support Unix permissions
if (process.platform === "win32") {
Expand Down