diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a2f7bfd..e28e0df 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,10 +4,15 @@ on: push: tags: - "*" - workflow_dispatch: permissions: contents: write + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false jobs: release: @@ -25,10 +30,24 @@ jobs: run: | npm install npm run compile + npm run docs + npm pack + mv packwiz-extras-lib*.tgz packwiz-extras-lib.tgz git log $(git describe --tags --abbrev=0 HEAD~1)..HEAD --oneline > changelog.txt - name: Release uses: softprops/action-gh-release@v2 with: make_latest: true body_path: changelog.txt - files: bin/*/* + files: | + packwiz-extras-lib.tgz + bin/*/* + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './docs' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index a8cd699..79acb33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules -bin +test +*.zip +dist .env -modpack.zip -test \ No newline at end of file +*.tgz +docs +bin \ No newline at end of file diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 0000000..7383dbd Binary files /dev/null and b/LICENCE.md differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..db67dc8 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Packwiz Extras + +This is a small helper program/library to implement some missing features from +[packwiz](https://github.com/packwiz/packwiz). +The CLI is licenced under GPLv3 or later. +The library is licenced under LGPLv3 or later. + +## CLI Usage + +``` +Usage: packwiz-extras [options] [command] + +extra utilities for packwiz + +Options: + --pack-file The modpack file to use (default: "pack.toml") + -h, --help display help for command + +Commands: + curseforge|cf [options] manage curseforge files + modrinth|mr [options] manage modrinth files + help [command] display help for command +``` +curseforge commands: +``` +Usage: packwiz-extras curseforge|cf [options] [command] + +manage curseforge files + +Options: + --api-key Your curseforge api key. This is required by curseforge to access their API. Can also be provided via environment variable or .env file + as CF_API_KEY + -h, --help display help for command + +Commands: + detect detect and replace files availible on curseforge + urls cache curseforge download urls to speed up packwiz-installer + help [command] display help for command +``` +modrinth commands: +``` +Usage: packwiz-extras modrinth|mr [options] [command] + +manage modrinth files + +Options: + --api-key Your modrinth api key, optional. + -h, --help display help for command + +Commands: + detect detect and replace files availible on modrinth + merge detect curseforge metafiles which are also availible on modrinth and merge their metadata + help [command] display help for command +``` +## Library Usage +For now, you can get the tarball for this library from the releases. +To add it to your npm project, copy the url and do: +``` +npm install https://github.com/sugoidogo/packwiz-extras/releases/download/x.y.z/packwiz-extras.tgz +``` +Library documentation can be found [online](https://sugoidogo.github.io/packwiz-extras/) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 50cf215..8ff2685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,27 @@ { - "name": "packwiz-extras-cli", + "name": "packwiz-extras", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "packwiz-extras", + "version": "0.2.0", + "license": "LGPL-3.0-or-later", "dependencies": { "commander": "^14.0.2", "confbox": "^0.2.2", - "deno": "^2.5.6", - "dotenv": "^17.2.3", - "packwiz-extras-lib": "https://github.com/sugoidogo/packwiz-extras-lib/releases/download/2.1.0/packwiz-extras-lib.tgz" + "murmur2": "^1.0.2", + "workerless": "https://github.com/sugoidogo/workerless/releases/download/0.1.0/workerless.tgz" }, "devDependencies": { - "@types/node": "^24.10.1" + "@types/node": "^24.10.1", + "@types/web": "^0.0.294", + "bun": "^1.3.3", + "deno": "^2.5.6", + "dotenv": "^17.2.3", + "typedoc": "^0.28.15", + "typescript": "^5.9.3" } }, "node_modules/@deno/darwin-arm64": { @@ -22,6 +31,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -35,6 +45,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -48,6 +59,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -61,6 +73,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -74,6 +87,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -87,12 +101,240 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.18.0.tgz", + "integrity": "sha512-zTAG1cXK5Q+T6CBEa8mqEnCx/H9rrpWEn+vhMbWikzmeO2jltY6zVE2m9YCO+xDi+P0vpBrOG1Xgi8AZtlNoUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.18.0", + "@shikijs/langs": "^3.18.0", + "@shikijs/themes": "^3.18.0", + "@shikijs/types": "^3.18.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.3.tgz", + "integrity": "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.3.tgz", + "integrity": "sha512-xGDePueVFrNgkS+iN0QdEFeRrx2MQ5hQ9ipRFu7N73rgoSSJsFlOKKt2uGZzunczedViIfjYl0ii0K4E9aZ0Ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.3.tgz", + "integrity": "sha512-1ij4wQ9ECLFf1XFry+IFUN+28if40ozDqq6+QtuyOhIwraKzXOlAUbILhRMGvM3ED3yBex2mTwlKpA4Vja/V2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.3.tgz", + "integrity": "sha512-DabZ3Mt1XcJneWdEEug8l7bCPVvDBRBpjUIpNnRnMFWFnzr8KBEpMcaWTwYOghjXyJdhB4MPKb19MwqyQ+FHAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.3.tgz", + "integrity": "sha512-XWQ3tV/gtZj0wn2AdSUq/tEOKWT4OY+Uww70EbODgrrq00jxuTfq5nnYP6rkLD0M/T5BHJdQRSfQYdIni9vldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.3.tgz", + "integrity": "sha512-7eIARtKZKZDtah1aCpQUj/1/zT/zHRR063J6oAxZP9AuA547j5B9OM2D/vi/F4En7Gjk9FPjgPGTSYeqpQDzJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.3.tgz", + "integrity": "sha512-IU8pxhIf845psOv55LqJyL+tSUc6HHMfs6FGhuJcAnyi92j+B1HjOhnFQh9MW4vjoo7do5F8AerXlvk59RGH2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.3.tgz", + "integrity": "sha512-xNSDRPn1yyObKteS8fyQogwsS4eCECswHHgaKM+/d4wy/omZQrXn8ZyGm/ZF9B73UfQytUfbhE7nEnrFq03f0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.3.tgz", + "integrity": "sha512-JoRTPdAXRkNYouUlJqEncMWUKn/3DiWP03A7weBbtbsKr787gcdNna2YeyQKCb1lIXE4v1k18RM3gaOpQobGIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.3.tgz", + "integrity": "sha512-kWqa1LKvDdAIzyfHxo3zGz3HFWbFHDlrNK77hKjUN42ycikvZJ+SHSX76+1OW4G8wmLETX4Jj+4BM1y01DQRIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.3.tgz", + "integrity": "sha512-u5eZHKq6TPJSE282KyBOicGQ2trkFml0RoUfqkPOJVo7TXGrsGYYzdsugZRnVQY/WEmnxGtBy4T3PAaPqgQViA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.18.0.tgz", + "integrity": "sha512-15+O2iy+nYU/IdiBIExXuK0JJABa/8tdnRDODBmLhdygQ43aCuipN5N9vTfS8jvkMByHMR09b5jtX2la0CCoOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.18.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.18.0.tgz", + "integrity": "sha512-Deq7ZoYBtimN0M8pD5RU5TKz7DhUSTPtQOBuJpMxPDDJ+MJ7nT90DEmhDM2V0Nzp6DjfTAd+Z7ibpzr8arWqiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.18.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.18.0.tgz", + "integrity": "sha512-wzg6vNniXC5J4ChNBJJIZFTWxmrERJMWknehmM++0OAKJqZ41WpnO7PmPOumvMsUaL1SC08Nb/JVdaJd2aTsZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.18.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.18.0.tgz", + "integrity": "sha512-YLmpuroH06TpvqRXKR0YqlI0nQ56c8+BO/m9A9ht36WRdxmML4ivUsnpXuJU7PiClLRD2M66ilY2YJ0KE+8q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -103,6 +345,78 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web": { + "version": "0.0.294", + "resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.294.tgz", + "integrity": "sha512-prZi2M3jd/lGeOhW9P3txXfn6/nXIJRn+w/aFHz94/dl04riO5V99n+sKSeIciq7FM2XY6wPWoNhNbm6S/AdMQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bun": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.3.tgz", + "integrity": "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bunx.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.3.3", + "@oven/bun-darwin-x64": "1.3.3", + "@oven/bun-darwin-x64-baseline": "1.3.3", + "@oven/bun-linux-aarch64": "1.3.3", + "@oven/bun-linux-aarch64-musl": "1.3.3", + "@oven/bun-linux-x64": "1.3.3", + "@oven/bun-linux-x64-baseline": "1.3.3", + "@oven/bun-linux-x64-musl": "1.3.3", + "@oven/bun-linux-x64-musl-baseline": "1.3.3", + "@oven/bun-windows-x64": "1.3.3", + "@oven/bun-windows-x64-baseline": "1.3.3" + } + }, "node_modules/commander": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", @@ -122,6 +436,7 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/deno/-/deno-2.5.6.tgz", "integrity": "sha512-SSe2gCf41A/uln54H7xdKZFtiDt+reBxYM4IaJLRH/NpEIE5T3VOcLSLHPaJzyiTvZe2xMz1QFcpg4cco5rNsA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -140,6 +455,7 @@ "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -148,23 +464,138 @@ "url": "https://dotenvx.com" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/murmur2": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/murmur2/-/murmur2-1.0.2.tgz", "integrity": "sha512-r9dVsfnmykIYovQH9oP2JeZu5/DYa9rZpSQw2FWR/VDoI0oxm/u21NF46bFH5h+x2Xv4q5+jHzsX95N6m0UO3Q==", "license": "MIT" }, - "node_modules/packwiz-extras-lib": { - "version": "2.1.0", - "resolved": "https://github.com/sugoidogo/packwiz-extras-lib/releases/download/2.1.0/packwiz-extras-lib.tgz", - "integrity": "sha512-mnP3x9TB/wN4a5Zs1l8pOBaBlr2B2spwHBdQ2vwrH+dMpzNb4Y/7LkzaBdQe8euQjBe2E9SmTyu71CPevmgeQQ==", - "license": "LGPL-3.0-or-later", - "dependencies": { - "confbox": "^0.2.2", - "murmur2": "^1.0.2", - "workerless": "https://github.com/sugoidogo/workerless/releases/download/0.1.0/workerless.tgz" + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, + "node_modules/typedoc": { + "version": "0.28.15", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.15.tgz", + "integrity": "sha512-mw2/2vTL7MlT+BVo43lOsufkkd2CJO4zeOSuWQQsiXoV2VuEn7f6IZp2jsUDPmBMABpgR0R5jlcJ2OGEFYmkyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.17.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "yaml": "^2.8.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -186,6 +617,22 @@ "dependencies": { "web-worker": "^1.5.0" } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 68b2682..41e1f23 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,46 @@ { + "name": "packwiz-extras", + "version": "0.2.0", + "description": "ts/js library for node and web for working with packwiz modpacks", + "keywords": [ + "minecraft" + ], + "homepage": "https://github.com/sugoidogo/packwiz-extras#readme", + "bugs": { + "url": "https://github.com/sugoidogo/packwiz-extras/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sugoidogo/packwiz-extras.git" + }, + "license": "LGPL-3.0-or-later", + "author": "SugoiDogo", "type": "module", + "main": "dist/packwiz.js", + "types": "./dist/packwiz.d.ts", + "files": [ + "dist", + "src" + ], "scripts": { "test": "node scripts/test.ts", - "compile": "node scripts/compile.ts" - }, - "devDependencies": { - "@types/node": "^24.10.1" + "prepack": "tsc -p src/lib/tsconfig.json", + "compile": "node scripts/compile.ts", + "docs": "typedoc src/lib/packwiz.ts" }, "dependencies": { "commander": "^14.0.2", "confbox": "^0.2.2", + "murmur2": "^1.0.2", + "workerless": "https://github.com/sugoidogo/workerless/releases/download/0.1.0/workerless.tgz" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/web": "^0.0.294", + "bun": "^1.3.3", "deno": "^2.5.6", "dotenv": "^17.2.3", - "packwiz-extras-lib": "https://github.com/sugoidogo/packwiz-extras-lib/releases/download/2.1.0/packwiz-extras-lib.tgz" + "typedoc": "^0.28.15", + "typescript": "^5.9.3" } } diff --git a/scripts/compile.ts b/scripts/compile.ts index d44db2a..da544a6 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -1,7 +1,7 @@ import spawnSync from "./spawnSync.ts" const deno = true // works, but produces largest executables -const bun = false // has weird issues +const bun = false // error: https://api.curseforge.com/v1/fingerprints: Forbidden const pkg = false // smallest executables but don't work if (deno) { @@ -17,7 +17,7 @@ if (deno) { spawnSync('deno', [ 'compile', '--allow-all', '--target', target, '--output', 'bin/deno/packwiz-extras-' + target, - '--no-check', 'src/cli.ts' + '--no-check', 'src/cli/cli.ts' ], { stdio: 'inherit', shell: true }) } } @@ -37,7 +37,7 @@ if (bun) { 'build', '--compile', '--target', target, '--outfile', 'bin/bun/packwiz-extras-' + target, - 'src/cli.ts' + 'src/cli/cli.ts' ], { stdio: 'inherit', shell: true }) } } @@ -57,6 +57,6 @@ if (pkg) { //'--sea', // doesn't build with this '--compress', 'GZip', //'--compress', 'Brotli', // same size as GZip and the internet says GZip is better - 'src/cli.ts' + 'src/cli/cli.ts' ], { stdio: 'inherit', shell: true }) } \ No newline at end of file diff --git a/scripts/test.ts b/scripts/test.ts index 4197700..b08c799 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,13 +1,53 @@ import fs from 'node:fs' import spawnSync from './spawnSync.ts' import download from './downloadProgress.ts' +import { performance } from 'node:perf_hooks' + +const deno = true // primary runtime +const bun = false // error: https://api.curseforge.com/v1/fingerprints: Forbidden +const node = true // supported runtime await download('https://mediafilez.forgecdn.net/files/7223/56/All%20the%20Mods%2010-5.1.zip', 'modpack.zip') +if (fs.existsSync('test')) fs.rmSync('test', { recursive: true }) +const times: any = {} -fs.mkdirSync('test', { recursive: true }) -spawnSync('packwiz', ['curseforge', 'import', '../modpack.zip'], { cwd: 'test' }) -spawnSync('deno', ['run', '--allow-all', 'src/cli.ts', '--pack-file=test/pack.toml', 'cf','detect']) -spawnSync('deno', ['run', '--allow-all', 'src/cli.ts', '--pack-file=test/pack.toml', 'cf','urls']) -spawnSync('deno', ['run', '--allow-all', 'src/cli.ts', '--pack-file=test/pack.toml', 'mr','detect']) -spawnSync('deno', ['run', '--allow-all', 'src/cli.ts', '--pack-file=test/pack.toml', 'mr','merge']) -fs.rmSync('test', { recursive: true }) \ No newline at end of file +if (node) { + fs.mkdirSync('test', { recursive: true }) + spawnSync('packwiz', ['curseforge', 'import', '../modpack.zip'], { cwd: 'test' }) + const start = performance.now() + spawnSync('node', ['src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'detect']) + spawnSync('node', ['src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'urls']) + spawnSync('node', ['src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'detect']) + spawnSync('node', ['src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'merge']) + const end = performance.now() + times['node'] = (end - start) / 1000 + fs.rmSync('test', { recursive: true }) +} + +if (deno) { + fs.mkdirSync('test', { recursive: true }) + spawnSync('packwiz', ['curseforge', 'import', '../modpack.zip'], { cwd: 'test' }) + const start = performance.now() + spawnSync('deno', ['run', '--allow-all', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'detect']) + spawnSync('deno', ['run', '--allow-all', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'urls']) + spawnSync('deno', ['run', '--allow-all', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'detect']) + spawnSync('deno', ['run', '--allow-all', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'merge']) + const end = performance.now() + times['deno'] = (end - start) / 1000 + fs.rmSync('test', { recursive: true }) +} + +if (bun) { + fs.mkdirSync('test', { recursive: true }) + spawnSync('packwiz', ['curseforge', 'import', '../modpack.zip'], { cwd: 'test' }) + const start = performance.now() + spawnSync('bun', ['run', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'detect']) + spawnSync('bun', ['run', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'cf', 'urls']) + spawnSync('bun', ['run', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'detect']) + spawnSync('bun', ['run', 'src/cli/cli.ts', '--pack-file=test/pack.toml', 'mr', 'merge']) + const end = performance.now() + times['bun'] = (end - start) / 1000 + fs.rmSync('test', { recursive: true }) +} + +console.log(times) \ No newline at end of file diff --git a/src/cli.ts b/src/cli/cli.ts similarity index 66% rename from src/cli.ts rename to src/cli/cli.ts index b93313f..f388891 100644 --- a/src/cli.ts +++ b/src/cli/cli.ts @@ -1,6 +1,6 @@ -import { Command, program } from "commander" +import { program } from "commander" import { config } from "dotenv" -import * as packwiz from 'packwiz-extras-lib' +import * as packwiz from '../lib/packwiz.ts' import * as fs from 'node:fs/promises' import { stringifyTOML } from 'confbox' import spawnSync from "./spawnSync.ts" @@ -10,60 +10,56 @@ async function refresh(packFile: packwiz.Path) { spawnSync('packwiz', ['refresh'], { cwd }) } -async function replace(results: Map) { +async function apply(result:packwiz.Result) { const jobs = [] - for (const [path, mod] of results.entries()) { - jobs.push(fs.rm(path)) - jobs.push(fs.writeFile(path + '.pw.toml', stringifyTOML(mod))) + if (result.delete !== undefined) { + for (const path of result.delete) { + jobs.push(fs.rm(path)) + } } - await Promise.all(jobs) -} - -async function overwrite(results: Map) { - const jobs = [] - for (const [path, mod] of results.entries()) { - jobs.push(fs.writeFile(path, stringifyTOML(mod))) + if (result.write!==undefined) { + for (const [path, data] of Object.entries(result.write)) { + jobs.push(fs.writeFile(path,stringifyTOML(data))) + } } - await Promise.all(jobs) } config({ 'quiet': true }) program.name('packwiz-extras') .description('extra utilities for packwiz') - .option('--pack-file ', 'The modpack file to use (default "pack.toml")', 'pack.toml') + .option('--pack-file ', 'The modpack file to use', 'pack.toml') const curseforge = program.command('curseforge').alias('cf') .description('manage curseforge files') -const cfKey = ['--api-key ', +const cfKey:string[] = ['--api-key ', 'Your curseforge api key. This is required by curseforge to access their API. ' + - 'Can also be provided via environment variable or .env file as CF_API_KEY', - process.env.CF_API_KEY] + 'Can also be provided via environment variable or .env file as CF_API_KEY',] -if (cfKey[2]) { - curseforge.option(cfKey[0], cfKey[1], cfKey[2]) +if (process.env.CF_API_KEY) { + curseforge.option(cfKey[0], cfKey[1]) } else { - curseforge.requiredOption(cfKey[0], cfKey[1], cfKey[2]) + curseforge.requiredOption(cfKey[0], cfKey[1]) } curseforge.command('detect') .description('detect and replace files availible on curseforge') .action(async function () { - const apiKey = curseforge.opts().apiKey + const apiKey = curseforge.opts().apiKey || process.env.CF_API_KEY const packFile = program.opts().packFile const result = await packwiz.cfDetect(packFile, apiKey) - await replace(result) + await apply(result) await refresh(packFile) }) curseforge.command('urls') .description('cache curseforge download urls to speed up packwiz-installer') .action(async function () { - const apiKey = curseforge.opts().apiKey + const apiKey = curseforge.opts().apiKey || process.env.CF_API_KEY const packFile = program.opts().packFile const result = await packwiz.cfUrl(packFile, apiKey) - await overwrite(result) + await apply(result) await refresh(packFile) }) @@ -78,7 +74,7 @@ modrith.command('detect') const apiKey = modrith.opts().apiKey const packFile = program.opts().packFile const result = await packwiz.mrDetect(packFile, apiKey) - await replace(result) + await apply(result) await refresh(packFile) }) @@ -88,7 +84,7 @@ modrith.command('merge') const apiKey = modrith.opts().apiKey const packFile = program.opts().packFile const result = await packwiz.mrMerge(packFile, apiKey) - await overwrite(result) + await apply(result) await refresh(packFile) }) diff --git a/src/logLevel.ts b/src/cli/logLevel.ts similarity index 100% rename from src/logLevel.ts rename to src/cli/logLevel.ts diff --git a/src/spawnSync.ts b/src/cli/spawnSync.ts similarity index 100% rename from src/spawnSync.ts rename to src/cli/spawnSync.ts diff --git a/tsconfig.json b/src/cli/tsconfig.json similarity index 60% rename from tsconfig.json rename to src/cli/tsconfig.json index 82c07c1..8c91052 100644 --- a/tsconfig.json +++ b/src/cli/tsconfig.json @@ -3,10 +3,11 @@ "target": "es2022", "module": "es2022", "moduleResolution": "node", + "lib": ["ESNext"], + "noEmit": true, "allowImportingTsExtensions": true, - "noEmit": true - }, - "include": [ - "src/" - ] + "types": [ + "node" + ] + } } \ No newline at end of file diff --git a/src/lib/packwiz.ts b/src/lib/packwiz.ts new file mode 100644 index 0000000..b2b8760 --- /dev/null +++ b/src/lib/packwiz.ts @@ -0,0 +1,648 @@ +/** + * The hash algorithm used to determine if a file is valid. + * All functions listed must be supported by tools implementing the packwiz pack format. + */ +export type HashFormat = "sha256" | "sha512" | "sha1" | "md5" | "murmur2" +/** + * A relative path using forward slashes. + * Must not reference a file outside the pack root, + * and should not include characters or filenames restricted on common operating systems. + * + * Implementations must support special characters including those that are percent-encoded in URLs, + * such as spaces and square brackets. + * Implementations must guard against path traversal attacks and manually validate paths. + */ +export type Path = string +/** + * Binary hashes should be stored as hexadecimal, + * and case should be ignored during parsing. + * Numeric hashes (e.g. Murmur2) should still be stored as a string, + * to ensure the value is preserved correctly. + */ +export type Hash = string +/** + * A physical Minecraft side. + * Server applies to the dedicated server, + * client applies to the client (and integrated server), + * and both applies to every installation. + */ +export type Side = "both" | "client" | "server" +/** + * An absolute URI compliant with RFC 3986. + * Implementations may need to be more lenient in accepting reserved characters in paths + * due to historical implementation bugs. + * Only the HTTP/HTTPS protocols must be supported, + * other protocols should not be used. + */ +export type Url = string +/** + * The main modpack file for a packwiz modpack. + * This is the first file loaded, + * to allow the modpack downloader to download all the files in the modpack. + */ +export interface Pack { + /** + * The name of the modpack. + * This can be displayed in user interfaces to identify the pack, + * and it does not need to be unique between packs. + */ + "name": string + /** + * The author(s) of the modpack. + * This is output when exporting to the CurseForge pack format, + * and can be displayed in user interfaces. + */ + "author"?: string + /** + * The version of the modpack. + * This is output when exporting to the CurseForge pack format, + * but is not currently used elsewhere by the tools or installer. + * It must not be used for determining if the modpack is outdated. + */ + "version"?: string + /** + * A short description of the modpack. + * This is output when exporting to the Modrinth pack format, + * but is not currently used elsewhere by the tools or installer. + */ + "description"?: string + /** + * A version string identifying the pack format and version of it. + * Currently, this pack format uses version 1.1.0. + * + * If it is not defined, default to "packwiz:1.0.0" + * for backwards-compatibility with packs created before this field was added. + * + * If it is defined: + * + * - All consumers should fail to load the modpack if it does not begin with "packwiz:" + * + * - All consumers should fail to load the modpack if the latter section is not valid semver + * as defined in https://semver.org/spec/v2.0.0.html + * + * - All consumers should fail to load the modpack if the major version is greater than the version they support + * + * - Consumers can suggest updating themselves if the minor version is greater than the version they implement + * + * - Pack tools should suggest and support migration when they support a version newer than this field + */ + "pack-format": string + /** Information about the index file in this modpack. */ + "index": { + /** The path to the file that contains the index. @see {@link Path} */ + "file": Path + /** The hash format for the hash of the index file. @see {@link HashFormat} */ + "hash-format": HashFormat + /** The hash of the index file, as a string. @see {@link Hash} */ + "hash": Hash + } + /** + * The versions of components used by this modpack - usually Minecraft and the mod loader this pack uses. + * The existence of a component implies that it should be installed. + * These values can also be used by tools to determine which versions of mods should be installed. + */ + "versions": { + /** + * The version of Minecraft used by this modpack. + * This should be in the format used by the version.json files. + */ + "minecraft": string + /** + * The version of Forge used by this modpack. + * This version must not include the Minecraft version as a prefix. + */ + "forge"?: string + /** The version of Fabric loader used by this modpack. */ + "fabric"?: string + /** The version of Quilt loader used by this modpack. */ + "quilt"?: string + /** The version of Liteloader used by this modpack. */ + "liteloader"?: string + "neoforge"?: string + }, + /** Additional options */ + "options"?: { + /** + * A list of additional Minecraft versions to accept when installing or updating mods. + * @see https://packwiz.infra.link/tutorials/creating/adding-mods/ + */ + "acceptable-game-versions"?: string[] + /** + * The folder in which new metadata files will be added, + * defaulting to a folder based on the category + * (mods, resourcepacks, etc; if the category is unknown the current directory is used) + */ + "meta-folder"?: string + /** deprecated; aliassed to meta-folder */ + "mods-folder"?: string + /** + * The base folder from which meta-folder will be resolved, + * defaulting to the current directory + * (so you can put all mods/etc in a subfolder while still using the default behaviour) + */ + "meta-folder-base"?: string + /** + * If this is set to true, + * packwiz will not generate hashes of local files, + * to prevent merge conflicts and inconsistent hashes when using git/etc. + * + * - `packwiz refresh --build` can be used in this mode to generate internal hashes + * for distributing the pack with packwiz-installer + */ + "no-internal-hashes"?: boolean + /** + * The folder in which datapacks are to be added; + * specific to the datapack loader mod you use, + * and must be set to add datapacks + * (that are not bundled as mods) + */ + "datapack-folder"?: string + } +} +/** + * The index file of the modpack, + * storing references to every file to be downloaded in the pack. + */ +export interface Index { + /** The default hash format for every file in the index. @see {@link HashFormat} */ + "hash-format": HashFormat + /** + * The files listed in this index. + * If it is not defined, defaults to an empty list. + */ + "files"?: [{ + /** + * The path to the file to be downloaded, + * relative to this index file. + * @see {@link Path} + */ + "file": Path + /** The hash of the specified file, as a string. @see {@link Hash} */ + "hash": Hash + /** + * The name with which this file should be downloaded, + * instead of the filename specified in the path. + * Not compatible with metafile, + * and may not be very well supported. + */ + "alias"?: string + /** + * The hash format for the hash of the specified file. + * Defaults to the hash format specified in the index - + * ideally remove this value if it is equal to the hash format for the index to save space. + * @see {@link HashFormat} + */ + "hash-format"?: HashFormat + /** + * True when this entry points to a .toml metadata file, + * which references a file outside the pack. + */ + "metafile"?: boolean + /** + * When this is set to true, + * the file is not overwritten if it already exists, + * to preserve changes made by a user. + */ + "preserve"?: boolean + }] +} +/** + * A metadata file which references an external file from a URL. + * This allows for side-only mods, + * optional mods and stores metadata to allow finding updates from Modrinth and CurseForge. + * The "mod" terminology is used a lot here, + * but this should work for any file. + */ +export interface Mod { + /** + * The destination path of the mod file, + * relative to this file. + * @see {@link Path} + */ + "filename": Path + /** + * The name of the mod, + * which can be displayed in user interfaces to identify the mod. + * It does not need to be unique between mods, + * although this may cause confusion. + */ + "name": string + /** The side on which this mod should be installed. @see {@link Side} */ + "side"?: Side + /** Information about how to download this mod. */ + "download": { + /** The hash format for the hash of the specified file. @see {@link HashFormat} */ + "hash-format": HashFormat + /** The hash of the specified file, as a string. @see {@link Hash} */ + "hash": Hash + /** The URL to download the mod from. @see {@link URL} */ + "url"?: Url + "mode"?: string + } + /** + * Information about the optional state of this mod. + * When excluded, this indicates that the mod is not optional. + */ + "option"?: { + /** + * Whether or not the mod is optional. + * This can be set to false if you want to keep the description but make the mod required. + */ + "optional": boolean + /** + * If true, the mod will be enabled by default. + * If false, the mod will be disabled by default. + * If a pack format does not support optional mods but it does support disabling mods, + * the mod will be disabled if it defaults to being disabled. + */ + "default"?: boolean + /** + * A description displayed to the user when they select optional mods. + * This should explain why or why not the user should enable the mod. + */ + "description"?: string + } + /** + * Information about how to update the download details of this mod with tools. + * + * If this object does not exist or there are no defined update sources, + * the mod will not be automatically updated. + * + * If there are multiple defined update sources, + * one of them will be chosen. + * The source that is chosen is not defined, + * so it is therefore dependent on the implementation of the tool + * (may not be deterministic, so do not rely on one source being chosen over another). + */ + "update"?: { + /** An update source for updating mods downloaded from CurseForge. */ + "curseforge"?: { + /** + * An integer representing the unique file ID of this mod file. + * This can be used if more metadata needs to be obtained relating to the mod. + */ + "file-id": number + /** + * An integer representing the unique project ID of this mod. + * Updating will retrieve the latest file for this project ID that is valid + * (correct Minecraft version, release channel, modloader, etc.). + */ + "project-id": number + } + /** An update source for updating mods downloaded from Modrinth. */ + "modrinth"?: { + /** + * A string representing the unique mod ID of this mod. + * Updating will retrieve the latest file for this project ID that is valid + * (correct Minecraft version, release channel, modloader, etc.). + */ + "mod-id": string + /** + * A string representing the unique version ID of this file. + * This can be used if more metadata needs to be obtained relating to the mod. + */ + "version": string + } + } +} + +export interface Result { + delete?: Path[] + write?: { [key: Path]: Mod | Index | Pack } +} + +import WorkerlessPool from 'workerless' +import { parseTOML } from 'confbox' + +// copied from https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API/Non-cryptographic_uses_of_subtle_crypto#hashing_a_file +async function sha1(arrayBuffer: BufferSource) { + const hashAsArrayBuffer = await crypto.subtle.digest("SHA-1", arrayBuffer); + const uint8ViewOfHash = new Uint8Array(hashAsArrayBuffer); + // @ts-ignore + if (uint8ViewOfHash.toHex) { + // @ts-ignore + return uint8ViewOfHash.toHex() as string; + } + const hashAsString = Array.from(uint8ViewOfHash) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return hashAsString; +} + +async function fetch_toml(url: string, options?: RequestInit) { + const response = await fetch_ok(url, options) + const text = await response.text() + return parseTOML(text) as any +} + +async function fetch_json(url: string, options?: RequestInit) { + const response = await fetch_ok(url, options) + const json = await response.json() + return json as any +} + +async function fetch_bytes(url: string, options?: RequestInit) { + const response = await fetch_ok(url, options) + const bytes = await response.bytes() + return bytes +} + +async function fetch_ok(url: string, options?: RequestInit) { + try { + new URL(url) + } catch { + // @ts-expect-error + const fs = await import('node:fs/promises') + const body = await fs.readFile(url) + return new Response(body) + } + const response = await fetch(url, options) + if (!response.ok) { + throw new Error(`${url}: ${response.statusText}`, { 'cause': response }) + } + return response +} + +function dirname(path: string) { + if (!path.startsWith('http') && !path.startsWith('./')) path = './' + path + const path_segments = path.split('/') + path_segments.pop() + path = path_segments.join('/') + return path +} + +export async function cfDetect(pack_url: string, cfApiKey: string, size_min = 4096): Promise { + console.log('loading pack') + const pack = await fetch_toml(pack_url) as Pack + const pack_dir = dirname(pack_url) + const index_url = `${pack_dir}/${pack.index.file}` + console.log('loading index') + const index = await fetch_toml(index_url) as Index + const index_dir = dirname(index_url) + if (!index.files) { + console.warn(`${index_url} has no files indexed`) + return {} + } + const file_hash_map = new Map() + console.log('hashing files') + const workerlessPool = new WorkerlessPool() + await Promise.all(index.files.map(async file => { + if (file.metafile) return + const file_url = `${index_dir}/${file.file}` + const file_data = await fetch_bytes(file_url) + if (file_data.length < size_min) return + const hash = await workerlessPool.run(async function (buffer: Uint8Array) { + const murmurHash = await import('murmur2').then(module => module.default) + return murmurHash(buffer, 1, true) + }, file_data) + file_hash_map.set(hash, file_url) + })) + workerlessPool.terminate() + console.log('requesting matches from curseforge') + const response = await fetch_json('https://api.curseforge.com/v1/fingerprints', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + 'x-api-key': cfApiKey + }, + body: JSON.stringify({ fingerprints: Array.from(file_hash_map.keys()) }) + }) + const result: Result = { delete: [], write: {} } + for (const match of response.data.exactMatches) { + let file_url = file_hash_map.get(match.file.fileFingerprint) + if (!file_url) { + for (const module of match.file.modules) { + file_url = file_hash_map.get(module.fingerprint) + } + if (file_url) { + console.debug("skipping module match, this is probably a false positive", file_url, match.file) + } else { + console.debug("curseforge returned a result we didn't ask for, skipping", match.file) + } + continue + } + for (const hash of match.file.hashes) { + if (hash.algo === 1) { + const mod: Mod = { + "name": match.file.displayName, + "filename": match.file.fileName, + "download": { + "hash-format": "sha1", + "hash": hash.value, + "mode": "metadata:curseforge" + }, + "update": { + "curseforge": { + "file-id": match.file.id, + "project-id": match.file.modId + } + } + } + result.delete.push(file_url) + result.write[match.file.displayName.toLowerCase().replace(' ', '-')] = mod + break + } + } + } + console.log(`found ${result.delete.length} matching files`) + return result +} + +export async function mrDetect(pack_url: string, mrApiKey?: string, size_min = 4096): Promise { + console.log('loading pack') + const pack = await fetch_toml(pack_url) as Pack + const pack_dir = dirname(pack_url) + const index_url = `${pack_dir}/${pack.index.file}` + console.log('loading index') + const index = await fetch_toml(index_url) as Index + const index_dir = dirname(index_url) + if (!index.files) { + console.warn(`${index_url} has no files indexed`) + return {} + } + const file_hash_map = new Map() + console.log('hashing files') + await Promise.all(index.files.map(async file => { + if (file.metafile) return + const file_url = `${index_dir}/${file.file}` + const file_data = await fetch_bytes(file_url) + if (file_data.length < size_min) return + const hash=await sha1(file_data) + file_hash_map.set(hash, file_url) + })) + console.log('requesting matches from modrinth') + const matches = await fetch_json('https://api.modrinth.com/v2/version_files', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + 'authorization': mrApiKey + }, + body: JSON.stringify({ + "algorithm": "sha1", + "hashes": Array.from(file_hash_map.keys()) + }) + }) + const project_matches = new Map() + for (const match of Object.values(matches) as any[]) project_matches.set(match.project_id, match) + const projects = await fetch_json(`https://api.modrinth.com/v2/projects?ids=["${Array.from(project_matches.keys()).join('","')}"]`) + + const result: Result = { delete: [], write: {} } + for (const project of projects) { + const match = project_matches.get(project.id) + if (!match) { + console.debug("modrinth returned a project result we didn't ask for, skipping", project) + continue + } + const file_url = file_hash_map.get(match.files[0].hashes.sha1) + if (!file_url) { + console.debug("modrinth returned a file result we didn't ask for, skipping", match) + continue + } + let side: Side = "both" + if (project.client_side === "unsupported") side = "server" + if (project.server_side === "unsupported") side = "client" + const mod: Mod = { + "name": project.title, + "filename": match.files[0].filename, + "side": side, + "download": { + "hash-format": "sha1", + "hash": match.files[0].hashes.sha1, + "url": match.files[0].url + }, + "update": { + "modrinth": { + "version": match.id, + "mod-id": match.project_id + } + } + } + result.delete.push(file_url) + result.write[match.file.displayName.toLowerCase().replace(' ', '-')] = mod + } + console.log(`found ${result.delete.length} matching files`) + return result +} + +export async function cfUrl(pack_url: string, cfApiKey: string): Promise { + console.log('loading pack') + const pack = await fetch_toml(pack_url) as Pack + const pack_dir = dirname(pack_url) + const index_url = `${pack_dir}/${pack.index.file}` + console.log('loading index') + const index = await fetch_toml(index_url) as Index + const index_dir = dirname(index_url) + if (!index.files) { + console.warn(`${index_url} has no files indexed`) + return {} + } + const mod_paths = new Map() + console.log('loading curseforge files') + await Promise.all(index.files.map(async file => { + if(!file.metafile) return + const file_url = `${index_dir}/${file.file}` + const mod = await fetch_toml(file_url) as Mod + if (!mod.update || !mod.update.curseforge) { + return + } + mod_paths.set(mod.update.curseforge['file-id'],{mod,file_url}) + })) + console.log(`requesting ${mod_paths.size} download urls`) + const response = await fetch_json('https://api.curseforge.com/v1/mods/files', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + 'x-api-key': cfApiKey + }, + body: JSON.stringify({'fileIds':Array.from(mod_paths.keys())}) + }) + const result: Result = { write: {} } + for (const file of response.data) { + if (!file.downloadUrl) { + console.warn('third party downloads disabled for ' + file.displayName) + continue + } + const { mod, file_url } = mod_paths.get(file.id)! + delete mod.download.mode + mod.download.url = file.downloadUrl + result.write[file_url] = mod + } + console.log(`found ${Object.keys(result.write).length} download urls`) + return result +} + +export async function mrMerge(pack_url: string, mrApiKey?: string): Promise { + console.log('loading pack') + const pack = await fetch_toml(pack_url) as Pack + const pack_dir = dirname(pack_url) + const index_url = `${pack_dir}/${pack.index.file}` + console.log('loading index') + const index = await fetch_toml(index_url) as Index + const index_dir = dirname(index_url) + if (!index.files) { + console.warn(`${index_url} has no files indexed`) + return {} + } + console.log('loading curseforge files') + const file_hash_map:any={} + await Promise.all(index.files.map(async function (file) { + if (!file.metafile) return + const file_url = `${index_dir}/${file.file}` + const mod = await fetch_toml(file_url) as Mod + if (mod.update && mod.update.modrinth) return + if (!file_hash_map[mod.download['hash-format']]) file_hash_map[mod.download['hash-format']] = {} + file_hash_map[mod.download['hash-format']][mod.download.hash]={file_url,mod} + })) + console.log('requesting matches from modrinth') + const project_matches = new Map() + for (const hash_format in file_hash_map) { + const matches = await fetch_json('https://api.modrinth.com/v2/version_files', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + 'authorization': mrApiKey + }, + body: JSON.stringify({ + "algorithm": hash_format, + "hashes": Array.from(Object.keys(file_hash_map[hash_format])) + }) + }) + for (const match of Object.values(matches) as any[]) project_matches.set(match.project_id, match) + } + const projects = await fetch_json(`https://api.modrinth.com/v2/projects?ids=["${Array.from(project_matches.keys()).join('","')}"]`) + const result: Result = { write: {} } + for (const project of projects) { + const match = project_matches.get(project.id) + if (!match) { + console.debug("modrinth returned a project result we didn't ask for, skipping", project) + continue + } + let file_url: Path|undefined + let mod: Mod|undefined + for (const hash_format in file_hash_map) { + ({file_url,mod}=file_hash_map[hash_format][match.files[0].hashes[hash_format].toLowerCase().replace('-','')]) + } + if (!file_url || !mod) { + console.debug("modrinth returned a file result we didn't ask for, skipping", match) + continue + } + delete mod.download.mode + mod.download.url = match.files[0].url + let side: Side = "both" + if (project.client_side === "unsupported") side = "server" + if (project.server_side === "unsupported") side = "client" + mod.side = side + if (!mod.update) mod.update = {} + mod.update.modrinth = { + "version": match.id, + "mod-id": match.project_id + } + result.write[file_url] = mod + } + console.log(`found ${Object.keys(result.write).length} matching files`) + return result +} \ No newline at end of file diff --git a/src/lib/tsconfig.json b/src/lib/tsconfig.json new file mode 100644 index 0000000..60a4e6f --- /dev/null +++ b/src/lib/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "node", + "lib": ["ESNext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "../../dist", + "types": ["web"] + }, + "include": [ + "packwiz.ts" + ] +} \ No newline at end of file