diff --git a/src/cli/cli.ts b/src/cli/cli.ts index a1a01c1..f4c6e37 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -10,6 +10,7 @@ import { signDxtFile, unsignDxtFile, verifyDxtFile } from "../node/sign.js"; import { validateManifest } from "../node/validate.js"; import { initExtension } from "./init.js"; import { packExtension } from "./pack.js"; +import { unpackExtension } from "./unpack.js"; // ES modules equivalent of __dirname const __filename = fileURLToPath(import.meta.url); @@ -94,6 +95,27 @@ program })(); }); +// Unpack command +program + .command("unpack [output]") + .description("Unpack a DXT extension file") + .action((dxtFile: string, output?: string) => { + void (async () => { + try { + const success = await unpackExtension({ + dxtPath: dxtFile, + outputDir: output, + }); + process.exit(success ? 0 : 1); + } catch (error) { + console.error( + `ERROR: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + process.exit(1); + } + })(); + }); + // Sign command program .command("sign ") diff --git a/src/cli/unpack.ts b/src/cli/unpack.ts new file mode 100644 index 0000000..f72f9b1 --- /dev/null +++ b/src/cli/unpack.ts @@ -0,0 +1,61 @@ +import { unzipSync } from "fflate"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join, resolve } from "path"; + +import { extractSignatureBlock } from "../node/sign.js"; +import { getLogger } from "../shared/log.js"; + +interface UnpackOptions { + dxtPath: string; + outputDir?: string; + silent?: boolean; +} + +export async function unpackExtension({ + dxtPath, + outputDir, + silent, +}: UnpackOptions): Promise { + const logger = getLogger({ silent }); + const resolvedDxtPath = resolve(dxtPath); + + if (!existsSync(resolvedDxtPath)) { + logger.error(`ERROR: DXT file not found: ${dxtPath}`); + return false; + } + + const finalOutputDir = outputDir ? resolve(outputDir) : process.cwd(); + + if (!existsSync(finalOutputDir)) { + mkdirSync(finalOutputDir, { recursive: true }); + } + + try { + const fileContent = readFileSync(resolvedDxtPath); + const { originalContent } = extractSignatureBlock(fileContent); + + const decompressed = unzipSync(originalContent); + + for (const relativePath in decompressed) { + if (Object.prototype.hasOwnProperty.call(decompressed, relativePath)) { + const data = decompressed[relativePath]; + const fullPath = join(finalOutputDir, relativePath); + const dir = join(fullPath, ".."); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(fullPath, data); + } + } + + logger.log(`Extension unpacked successfully to ${finalOutputDir}`); + return true; + } catch (error) { + if (error instanceof Error) { + logger.error(`ERROR: Failed to unpack extension: ${error.message}`); + } else { + logger.error("ERROR: An unknown error occurred during unpacking."); + } + return false; + } +} diff --git a/src/index.ts b/src/index.ts index 0e01556..eea22d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Default export includes everything (backward compatibility) export * from "./cli/init.js"; export * from "./cli/pack.js"; +export * from "./cli/unpack.js"; export * from "./node/files.js"; export * from "./node/sign.js"; export * from "./node/validate.js"; diff --git a/src/node/sign.ts b/src/node/sign.ts index 48b311d..f87c570 100644 --- a/src/node/sign.ts +++ b/src/node/sign.ts @@ -241,7 +241,7 @@ function createSignatureBlock(pkcs7Signature: Buffer): Buffer { /** * Extracts the signature block from a signed DXT file */ -function extractSignatureBlock(fileContent: Buffer): { +export function extractSignatureBlock(fileContent: Buffer): { originalContent: Buffer; pkcs7Signature?: Buffer; } { diff --git a/test/cli.test.ts b/test/cli.test.ts index e059aae..81e91cf 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -77,4 +77,99 @@ describe("DXT CLI", () => { // Clean up fs.unlinkSync(invalidJsonPath); }); + + describe("pack and unpack", () => { + const tempDir = join(__dirname, "temp-pack-test"); + const packedFilePath = join(__dirname, "test-extension.dxt"); + const unpackedDir = join(__dirname, "temp-unpack-test"); + + beforeAll(() => { + // Ensure the CLI is built + execSync("yarn build", { cwd: join(__dirname, "..") }); + // Create a temporary directory with some files + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync( + join(tempDir, "manifest.json"), + JSON.stringify({ + dxt_version: "1.0", + name: "Test Extension", + version: "1.0.0", + description: "A test extension", + author: { + name: "DXT", + }, + server: { + type: "node", + entry_point: "server/index.js", + mcp_config: { + command: "node", + }, + }, + }), + ); + fs.writeFileSync(join(tempDir, "file1.txt"), "hello"); + fs.mkdirSync(join(tempDir, "subdir")); + fs.writeFileSync(join(tempDir, "subdir", "file2.txt"), "world"); + }); + + afterAll(() => { + // Clean up temporary files and directories + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(unpackedDir, { recursive: true, force: true }); + if (fs.existsSync(packedFilePath)) { + fs.unlinkSync(packedFilePath); + } + }); + + it("should pack an extension", () => { + execSync(`node ${cliPath} pack ${tempDir} ${packedFilePath}`, { + encoding: "utf-8", + }); + expect(fs.existsSync(packedFilePath)).toBe(true); + }); + + it("should unpack an extension", () => { + execSync(`node ${cliPath} unpack ${packedFilePath} ${unpackedDir}`, { + encoding: "utf-8", + }); + expect(fs.existsSync(unpackedDir)).toBe(true); + expect(fs.existsSync(join(unpackedDir, "manifest.json"))).toBe(true); + expect(fs.existsSync(join(unpackedDir, "file1.txt"))).toBe(true); + expect(fs.existsSync(join(unpackedDir, "subdir", "file2.txt"))).toBe( + true, + ); + }); + + it("should have the same content after packing and unpacking", () => { + const originalManifest = fs.readFileSync( + join(tempDir, "manifest.json"), + "utf-8", + ); + const unpackedManifest = fs.readFileSync( + join(unpackedDir, "manifest.json"), + "utf-8", + ); + expect(originalManifest).toEqual(unpackedManifest); + + const originalFile1 = fs.readFileSync( + join(tempDir, "file1.txt"), + "utf-8", + ); + const unpackedFile1 = fs.readFileSync( + join(unpackedDir, "file1.txt"), + "utf-8", + ); + expect(originalFile1).toEqual(unpackedFile1); + + const originalFile2 = fs.readFileSync( + join(tempDir, "subdir", "file2.txt"), + "utf-8", + ); + const unpackedFile2 = fs.readFileSync( + join(unpackedDir, "subdir", "file2.txt"), + "utf-8", + ); + expect(originalFile2).toEqual(unpackedFile2); + }); + }); });