commit 19ee5de1b9f253118157eaff6f5c16dea11dbdee Author: Ben Carter Date: Thu Feb 27 00:10:52 2025 -0500 Initial Commit diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..ce57cdb --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,53 @@ +name: NPM Publish + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Generate Release Notes + id: generate_notes + uses: TriPSs/conventional-changelog-action@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + skip-version-file: true + skip-commit: true + skip-tag: true + output-file: false + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: ${{ steps.generate_notes.outputs.clean_changelog }} + draft: false + prerelease: false + + publish: + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18.x' + registry-url: 'https://registry.npmjs.org' + scope: '@araxiaonline' + - run: npm ci + - run: npm test + - run: npm run build + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81bb7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +mpqcli/build/ +bin/ +*.log +.DS_Store +extracted/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..90a9786 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# mpq-tools-osx + +A Node.js wrapper for manipulating MPQ archives using mpqcli. This package provides a simple interface to list, extract, and create MPQ archives commonly used in Blizzard games. + +## Prerequisites + +- Node.js >= 14.0.0 +- CMake (for building mpqcli) +- C++ compiler (for building mpqcli) + +## Installation + +```bash +npm install mpq-tools-osx +``` + +The package will automatically build mpqcli during installation. + +## Usage + +```javascript +const MPQTool = require('mpq-tools'); +const mpq = new MPQTool(); + +// List all files in an MPQ archive +const files = mpq.listFiles('path/to/archive.mpq'); +console.log(`Found ${files.length} files`); + +// Search for specific files +const swordSounds = mpq.searchFiles('path/to/archive.mpq', 'Sword'); +console.log(`Found ${swordSounds.length} sword sound files`); + +// Extract a specific file +mpq.extractFile('path/to/archive.mpq', 'path/inside/archive.txt', 'output/dir'); + +// Extract all files +mpq.extractAll('path/to/archive.mpq', 'output/dir'); + +// Create a new MPQ archive from a directory +mpq.createArchive('directory/to/archive', 2); // version 2 MPQ +``` + +## API + +### `new MPQTool(options)` + +Create a new MPQTool instance. + +Options: +- `mpqcliPath`: Optional path to the mpqcli executable. By default, it uses the bundled version. + +### `listFiles(mpqFile)` + +List all files in an MPQ archive. + +- `mpqFile`: Path to the MPQ file +- Returns: Array of file paths in the archive + +### `searchFiles(mpqFile, pattern)` + +Search for files in an MPQ archive using a pattern. + +- `mpqFile`: Path to the MPQ file +- `pattern`: Search pattern (case-insensitive) +- Returns: Array of matching file paths + +### `extractFile(mpqFile, filePath, outputDir)` + +Extract a specific file from an MPQ archive. + +- `mpqFile`: Path to the MPQ file +- `filePath`: Path of the file within the archive to extract +- `outputDir`: Optional output directory +- Returns: true if successful + +### `extractAll(mpqFile, outputDir)` + +Extract all files from an MPQ archive. + +- `mpqFile`: Path to the MPQ file +- `outputDir`: Optional output directory +- Returns: true if successful + +### `createArchive(directory, version)` + +Create a new MPQ archive from a directory. + +- `directory`: Directory to create MPQ from +- `version`: MPQ version (1 or 2, default: 2) +- Returns: true if successful + +### Example command line command +```bash +$ node -e "const MPQTool = require('./lib'); const mpq = new MPQTool(); mpq.addFile('patch-C.MPQ', 'test.txt', 'custom/test.txt');" +``` + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/examples/add-files.js b/examples/add-files.js new file mode 100644 index 0000000..b741597 --- /dev/null +++ b/examples/add-files.js @@ -0,0 +1,27 @@ +const MPQTool = require('../lib'); +const path = require('path'); +const fs = require('fs'); + +const mpq = new MPQTool(); + +// Create a test directory structure +const testDir = path.join(__dirname, 'test-files'); +const customPath = path.join(testDir, 'custom', 'path'); +if (!fs.existsSync(customPath)) { + fs.mkdirSync(customPath, { recursive: true }); +} + +// Create some test files +fs.writeFileSync(path.join(customPath, 'test1.txt'), 'Hello World!'); +fs.writeFileSync(path.join(customPath, 'test2.txt'), 'Another test file'); + +// Create an MPQ from our directory +const outputMpq = path.join(__dirname, 'test.mpq'); +console.log('Creating new MPQ archive from directory...'); +const mpqPath = mpq.createArchive(testDir, 2, outputMpq); +console.log(`Created MPQ at: ${mpqPath}`); + +// List files in the archive to verify +console.log('\nFiles in the archive:'); +const files = mpq.listFiles(mpqPath); +files.forEach(file => console.log(file)); diff --git a/examples/patch-z.mpq b/examples/patch-z.mpq new file mode 100644 index 0000000..196b57f Binary files /dev/null and b/examples/patch-z.mpq differ diff --git a/examples/test-files/custom/path/test1.txt b/examples/test-files/custom/path/test1.txt new file mode 100644 index 0000000..c57eff5 --- /dev/null +++ b/examples/test-files/custom/path/test1.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/examples/test-files/custom/path/test2.txt b/examples/test-files/custom/path/test2.txt new file mode 100644 index 0000000..b2679d1 --- /dev/null +++ b/examples/test-files/custom/path/test2.txt @@ -0,0 +1 @@ +Another test file \ No newline at end of file diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..09f4fad --- /dev/null +++ b/lib/index.js @@ -0,0 +1,280 @@ +const { execSync } = require('child_process'); +const path = require('path'); +const os = require('os'); + +class MPQTool { + constructor(options = {}) { + // Get platform-specific information + const platform = os.platform(); + const arch = os.arch(); + + // Use the pre-built binary for the current platform + const binaryName = platform === 'win32' ? 'mpqcli.exe' : 'mpqcli'; + const defaultPath = path.join(__dirname, '..', 'bin', `${platform}-${arch}`, binaryName); + + // Allow overriding the mpqcli path + this.mpqcliPath = options.mpqcliPath || defaultPath; + + // Verify the binary exists + if (!require('fs').existsSync(this.mpqcliPath)) { + throw new Error(`mpqcli binary not found at ${this.mpqcliPath}. Make sure to run 'npm run build' first.`); + } + } + + /** + * List all files in an MPQ archive + * @param {string} mpqFile - Path to the MPQ file + * @returns {string[]} Array of file paths in the archive + */ + listFiles(mpqFile) { + try { + const absolutePath = path.resolve(mpqFile); + const cmd = `${this.mpqcliPath} list "${absolutePath}"`; + + const output = execSync(cmd, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + return output.split('\n').filter(line => line.trim()); + } catch (error) { + if (error.stdout) { + // Even though it's an error, the command might have output + return error.stdout.split('\n').filter(line => line.trim()); + } + throw new Error(`Failed to list files: ${error.message}`); + } + } + + /** + * Extract all files from an MPQ archive + * @param {string} mpqFile - Path to the MPQ file + * @param {string} outputDir - Directory to extract files to + */ + extractAll(mpqFile, outputDir) { + try { + const fs = require('fs'); + const absoluteMpqPath = path.resolve(mpqFile); + const absoluteOutputDir = path.resolve(outputDir); + + // Create output directory if it doesn't exist + fs.mkdirSync(absoluteOutputDir, { recursive: true }); + + // Extract files + const cmd = `${this.mpqcliPath} extract -o "${absoluteOutputDir}" "${absoluteMpqPath}"`; + execSync(cmd, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + + // Fix path separators in extracted files + const files = this.listFiles(mpqFile); + files.forEach(file => { + const extractedPath = path.join(absoluteOutputDir, file); + const normalizedPath = path.join(absoluteOutputDir, file.replace(/\\/g, path.sep)); + + // Skip if paths are the same + if (extractedPath === normalizedPath) return; + + // Create parent directory + fs.mkdirSync(path.dirname(normalizedPath), { recursive: true }); + + // Move file to correct location + if (fs.existsSync(extractedPath)) { + fs.renameSync(extractedPath, normalizedPath); + } + }); + } catch (error) { + throw new Error(`Failed to extract archive: ${error.message}`); + } + } + + /** + * Extract specific files from an MPQ archive + * @param {string} mpqFile - Path to the MPQ file + * @param {string} filePath - Path of the file within the archive to extract + * @param {string} outputDir - Optional output directory + * @returns {boolean} Success status + */ + extractFile(mpqFile, filePath, outputDir = null) { + try { + const absolutePath = path.resolve(mpqFile); + const cmd = outputDir + ? `${this.mpqcliPath} extract -o "${path.resolve(outputDir)}" -f "${filePath}" "${absolutePath}"` + : `${this.mpqcliPath} extract -f "${filePath}" "${absolutePath}"`; + + execSync(cmd, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + return true; + } catch (error) { + throw new Error(`Failed to extract file: ${error.message}`); + } + } + + /** + * Create a new MPQ archive from a directory + * @param {string} directory - Directory to create MPQ from + * @param {number} version - MPQ version (1 or 2) + * @param {string} [outputPath] - Optional path for the MPQ file. If not provided, creates it next to the directory + * @returns {string} Path to the created MPQ file + */ + createArchive(directory, version = 2, outputPath = null) { + try { + const absoluteDir = path.resolve(directory); + const dirName = path.basename(absoluteDir); + const workingDir = path.dirname(absoluteDir); + + // Create the MPQ in the directory's parent + const cmd = `${this.mpqcliPath} create -v ${version} "${dirName}"`; + execSync(cmd, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + cwd: workingDir + }); + + // MPQ is created as directory.mpq in the working directory + const createdMpq = path.join(workingDir, dirName + '.mpq'); + + // Move to final destination if specified + if (outputPath) { + const finalPath = path.resolve(outputPath); + const finalDir = path.dirname(finalPath); + + // Create output directory if needed + if (!require('fs').existsSync(finalDir)) { + require('fs').mkdirSync(finalDir, { recursive: true }); + } + + // Move the MPQ file + require('fs').renameSync(createdMpq, finalPath); + return finalPath; + } + + return createdMpq; + } catch (error) { + throw new Error(`Failed to create archive: ${error.message}`); + } + } + + /** + * Helper method to recursively copy a directory + * @private + */ + _copyDirectory(src, dest) { + const fs = require('fs'); + const path = require('path'); + + // Create destination directory + fs.mkdirSync(dest, { recursive: true }); + + // Read directory contents + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + this._copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } + } + + /** + * Search for files in an MPQ archive using a pattern + * @param {string} mpqFile - Path to the MPQ file + * @param {string} pattern - Search pattern + * @returns {string[]} Array of matching file paths + */ + searchFiles(mpqFile, pattern) { + try { + const files = this.listFiles(mpqFile); + return files.filter(file => file.toLowerCase().includes(pattern.toLowerCase())); + } catch (error) { + throw new Error(`Failed to search files: ${error.message}`); + } + } + + /** + * Add files or directories to an existing MPQ archive + * @param {string} mpqFile - Path to the MPQ file + * @param {string} sourcePath - Path to the file or directory to add + * @param {string} [targetPath] - Optional path within the MPQ where the file/directory should be placed + * @returns {boolean} Success status + */ + addToArchive(mpqFile, sourcePath, targetPath = '') { + try { + const absoluteMpqPath = path.resolve(mpqFile); + const absoluteSourcePath = path.resolve(sourcePath); + + let cmd = `${this.mpqcliPath} add "${absoluteMpqPath}" "${absoluteSourcePath}"`; + if (targetPath) { + cmd += ` -p "${targetPath}"`; + } + + execSync(cmd, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + return true; + } catch (error) { + throw new Error(`Failed to add to archive: ${error.message}`); + } + } + + /** + * Add a single file to an existing MPQ archive + * @param {string} mpqFile - Path to the MPQ file + * @param {string} filePath - Path to the file to add + * @param {string} [targetPath] - Path within the MPQ where the file should be placed + * @returns {boolean} Success status + */ + addFile(mpqFile, filePath, targetPath = null) { + try { + const fs = require('fs'); + const absoluteMpqPath = path.resolve(mpqFile); + const absoluteFilePath = path.resolve(filePath); + + // Create a temporary directory + const tempDir = path.join(require('os').tmpdir(), 'mpq-tool-' + Date.now()); + fs.mkdirSync(tempDir, { recursive: true }); + + // Extract current MPQ contents + console.log('Extracting current MPQ contents...'); + this.extractAll(absoluteMpqPath, tempDir); + + // Copy new file to temp directory + // Normalize path separators for MPQ + const finalPath = (targetPath || path.basename(absoluteFilePath)) + .split(/[/\\]/) // Split on both forward and back slashes + .join('\\'); // Join with backslashes + const targetFilePath = path.join(tempDir, finalPath); + + // Create parent directories if needed + fs.mkdirSync(path.dirname(targetFilePath), { recursive: true }); + + // Copy the file + fs.copyFileSync(absoluteFilePath, targetFilePath); + + // Create new MPQ with all files + console.log('Creating new MPQ with added file...'); + const tempMpq = path.join(require('os').tmpdir(), 'temp.mpq'); + this.createArchive(tempDir, 2, tempMpq); + + // Replace original MPQ + fs.renameSync(tempMpq, absoluteMpqPath); + + // Clean up + fs.rmSync(tempDir, { recursive: true, force: true }); + + return true; + } catch (error) { + throw new Error(`Failed to add file: ${error.message}`); + } + } +} + +module.exports = MPQTool; diff --git a/mpqcli b/mpqcli new file mode 160000 index 0000000..2dd551c --- /dev/null +++ b/mpqcli @@ -0,0 +1 @@ +Subproject commit 2dd551cbdc468b14bec9d3ebce2450e26f7f39fd diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..759a2ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,88 @@ +{ + "name": "mpq-tool", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mpq-tool", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "stormlib-node": "^1.3.6" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/stormlib-node": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/stormlib-node/-/stormlib-node-1.3.6.tgz", + "integrity": "sha512-LBLo1fzTRd+KOAZUmsWhKNHf8gOJ+YEiu7GTXNuXE5jWXk5abdFQOpOz1FYxW+fDXdnq9sD/t2XyQqA9SJiCJw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^11.1.1", + "node-addon-api": "^6.1.0", + "typescript": "^5.1.3" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee8ef0d --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@araxiaonline/mpq-tools-osx", + "version": "1.0.0", + "description": "Node.js wrapper for MPQ archive manipulation for MacOSX using mpqcli", + "main": "lib/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "node scripts/build.js", + "prepare": "npm run build", + "install": "node scripts/build.js", + "postinstall": "cd mpqcli && cmake -B build && cmake --build build", + "publish": "npm publish --access public" + }, + "keywords": [ + "mpq", + "wow", + "warcraft", + "archive", + "stormlib" + ], + "author": "ben-of-codecraft", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/araxiaonline/mpq-tools-osx" + }, + "files": [ + "lib/", + "bin/", + "mpqcli/", + "scripts/", + "README.md" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ] +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..56b0358 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,42 @@ +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// Get platform-specific information +const platform = os.platform(); +const arch = os.arch(); + +// Define the build directory +const buildDir = path.join(__dirname, '..', 'bin', `${platform}-${arch}`); + +// Create the build directory if it doesn't exist +if (!fs.existsSync(buildDir)) { + fs.mkdirSync(buildDir, { recursive: true }); +} + +// Build mpqcli +console.log(`Building mpqcli for ${platform}-${arch}...`); + +try { + // Navigate to mpqcli directory + const mpqcliDir = path.join(__dirname, '..', 'mpqcli'); + process.chdir(mpqcliDir); + + // Build using CMake + execSync('cmake -B build', { stdio: 'inherit' }); + execSync('cmake --build build', { stdio: 'inherit' }); + + // Copy the binary to the platform-specific directory + const binaryName = platform === 'win32' ? 'mpqcli.exe' : 'mpqcli'; + const binaryPath = path.join('build', 'bin', binaryName); + const targetPath = path.join(buildDir, binaryName); + + fs.copyFileSync(binaryPath, targetPath); + fs.chmodSync(targetPath, 0o755); // Make executable + + console.log(`Successfully built mpqcli and copied to ${targetPath}`); +} catch (error) { + console.error('Failed to build mpqcli:', error.message); + process.exit(1); +}