start over

This commit is contained in:
2026-02-09 16:56:28 -08:00
parent fad7c4600a
commit 79ee7fc098
5 changed files with 405 additions and 848 deletions
+60 -25
View File
@@ -9,9 +9,10 @@
"version": "0.2.0",
"license": "LGPL-3.0-or-later",
"dependencies": {
"@modrinth-ts/lib": "^2.1.0",
"@sugoidogo/js-util": "^0.3.1",
"@sugoidogo/node-file-system-adapter": "^1.0.3",
"@xmcl/curseforge": "^2.2.0",
"@xmcl/modrinth": "^2.5.0",
"commander": "^14.0.3",
"confbox": "^0.2.4",
"curseforge-v2": "^1.5.0",
@@ -22,9 +23,9 @@
},
"devDependencies": {
"@sugoidogo/importable-types-web": "^1.0.0",
"@sugoidogo/node-web-globals": "^2.0.0",
"@types/md5": "^2.3.6",
"@types/node": "^25.2.2",
"@types/web": "^0.0.329",
"deno": "^2.6.8",
"typedoc": "^0.28.16",
"typescript": "^5.9.3",
@@ -1224,18 +1225,6 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@modrinth-ts/lib": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@modrinth-ts/lib/-/lib-2.1.0.tgz",
"integrity": "sha512-W+HGQgQSmK/bmQO2hjEQROwMRZUSH+uigke4Zat1DzeBjsKGIotEy2inuIXX+b0uh6SqRwb4grpbJKk/VPMT8Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.1"
},
"peerDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -1372,6 +1361,16 @@
"node": ">=16"
}
},
"node_modules/@sugoidogo/node-web-globals": {
"version": "2.0.0",
"resolved": "https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/%40sugoidogo%2Fnode-web-globals/-/2.0.0/node-web-globals-2.0.0.tgz",
"integrity": "sha512-bPFW3kBVj5pmshivjLQE/ZWKs4+6hx2rQc7YSgzK9RX48pjAeJ9/9p5/45M3Wg4KIxNi+8Jz7WpIntcDYovuHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"undici-types": "^7.21.0"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -1413,12 +1412,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/web": {
"version": "0.0.329",
"resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.329.tgz",
"integrity": "sha512-bN6arw3cJ1P607brIP16VVP7TA+jrMA5RdV1bEbi9UshLSzLQ8EVM4AsZo5/hhGRbB3n9eExbCem+GUohXv+Ng==",
"dev": true,
"license": "Apache-2.0"
"node_modules/@xmcl/curseforge": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@xmcl/curseforge/-/curseforge-2.2.0.tgz",
"integrity": "sha512-W1b92Z1IYItIyplq2poWP1NL47buE4LOHWEU+duHCj0xWHI5y4ya2pIyXQhNzsBCSJ+ZZBXzPx/27hBl2PUwcA==",
"license": "MIT",
"dependencies": {
"undici": "7.1.1"
},
"engines": {
"node": ">=16.4"
}
},
"node_modules/@xmcl/curseforge/node_modules/undici": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.1.1.tgz",
"integrity": "sha512-WZkQ6eH9f5ZT93gaIffsbUaDpBwjbpvmMbfaEhOnbdUneurTESeRxwPGwjI28mRFESH3W3e8Togijh37ptOQqA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/@xmcl/modrinth": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@xmcl/modrinth/-/modrinth-2.5.0.tgz",
"integrity": "sha512-OpPpBemZ/yYvphgLVXGV+jESR9n8OceEsMaMvxuFZrIpi9LdYTa2oyVlnZHD7Dte+S+ttD8Y2AUncSJUNFRgNg==",
"license": "MIT",
"dependencies": {
"undici": "7.2.3"
},
"engines": {
"node": ">=16.4"
}
},
"node_modules/@xmcl/modrinth/node_modules/undici": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.2.3.tgz",
"integrity": "sha512-2oSLHaDalSt2/O/wHA9M+/ZPAOcU2yrSP/cdBYJ+YxZskiPYDSqHbysLSlD7gq3JMqOoJI5O31RVU3BxX/MnAA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
@@ -1434,13 +1468,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -2150,6 +2184,7 @@
"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",
"peer": true,
"bin": {
+3 -2
View File
@@ -29,9 +29,10 @@
"docs": "typedoc src/lib/packwiz.ts --tsconfig src/lib/tsconfig.json"
},
"dependencies": {
"@modrinth-ts/lib": "^2.1.0",
"@sugoidogo/js-util": "^0.3.1",
"@sugoidogo/node-file-system-adapter": "^1.0.3",
"@xmcl/curseforge": "^2.2.0",
"@xmcl/modrinth": "^2.5.0",
"commander": "^14.0.3",
"confbox": "^0.2.4",
"curseforge-v2": "^1.5.0",
@@ -42,9 +43,9 @@
},
"devDependencies": {
"@sugoidogo/importable-types-web": "^1.0.0",
"@sugoidogo/node-web-globals": "^2.0.0",
"@types/md5": "^2.3.6",
"@types/node": "^25.2.2",
"@types/web": "^0.0.329",
"deno": "^2.6.8",
"typedoc": "^0.28.16",
"typescript": "^5.9.3",
+31 -816
View File
@@ -1,365 +1,8 @@
/**
* 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 PackMetaData {
/**
* 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"?: Path
/** deprecated; aliassed to meta-folder */
"mods-folder"?: Path
/**
* 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"?: Path
/**
* 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"?: Path
}
}
/**
* The index file of the modpack,
* storing references to every file to be downloaded in the pack.
*/
export interface PackIndex {
/** 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"?: Path
/**
* 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 PackFileMetaData {
/**
* 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
}
}
}
import type { FileSystemDirectoryHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions } from "@sugoidogo/importable-types-web"
import type { PackFileMetaData, PackIndex, PackMetaData, Path, Url } from "./types"
import { parseTOML } from "confbox"
import WorkerlessPool from 'workerless'
import { parseTOML, stringifyTOML } from 'confbox'
import { CFV2Client } from 'curseforge-v2'
import { forAsync } from '@sugoidogo/js-util'
import * as modrinth from '@modrinth-ts/lib/dist/index'
import type {
Crypto,
FileSystemDirectoryHandle,
FileSystemGetDirectoryOptions,
FileSystemGetFileOptions,
TextDecoder,
TextEncoder,
Console
} from '@sugoidogo/importable-types-web'
declare var TextEncoder: {
prototype: TextEncoder;
new(): TextEncoder;
};
declare var TextDecoder: {
prototype: TextDecoder
new(): TextDecoder
}
declare var crypto: Crypto
declare var console: Console
export async function getHash(data: Uint8Array<ArrayBuffer> | string, format: HashFormat): Promise<string | number> {
if (typeof data === 'string') data = new TextEncoder().encode(data)
if (format.startsWith('sha')) { // copied from https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API/Non-cryptographic_uses_of_subtle_crypto#hashing_a_file
const hashAsArrayBuffer = await crypto.subtle.digest("SHA-" + format.substring(3), data);
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;
}
if (format === "murmur2") {
const { default: murmurHash } = await import('murmur2')
return murmurHash(data, 1, true) as number
}
if (format === "md5") {
const md5 = await import('md5')
return md5(data)
}
throw new Error('unknown hash format: ' + format)
}
const pathSeperatorRegex = /[\\\/]/
function splitPath(path: string) {
path = path.replaceAll('\\', '/')
@@ -387,464 +30,36 @@ async function getFileHandle(dir: FileSystemDirectoryHandle, path: string, optio
return dir.getFileHandle(basename, options)
}
async function removeEntry(dir: FileSystemDirectoryHandle, path: string) {
const { dirname, basename } = splitPath(path)
dir = await getDirectoryHandle(dir, dirname)
return dir.removeEntry(basename)
async function getBytes(origin: FileSystemDirectoryHandle | Url, filePath: Path) {
if (typeof origin === "string") {
const url=new URL(origin)
if (!url.pathname.endsWith(filePath)) {
const pathSegments = url.pathname.split(pathSeperatorRegex)
if (pathSegments.at(-1) === "") pathSegments.pop()
pathSegments.push(filePath)
url.pathname=pathSegments.join("/")
}
return fetch(url).then(response=>response.bytes())
}
return getFileHandle(origin, filePath)
.then(fileHandle => fileHandle.getFile())
.then(file=>file.bytes())
}
class Modpack {
pack_directory_handle: FileSystemDirectoryHandle
pack_file_name: string
pack: PackMetaData
index: PackIndex
files: { [key: Path]: PackFileMetaData }
constructor({ pack_file_name = 'pack.toml', pack_directory_handle }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle
}) {
this.pack_file_name = pack_file_name
this.pack_directory_handle = pack_directory_handle
}
// initialize this modpack with default values
async init() {
this.index = {
'hash-format': 'sha256'
}
this.pack = {
'name': 'modpack',
'pack-format': 'packwiz:1.1.0',
'index': {
'file': 'index.toml',
'hash-format': 'sha256',
'hash': await getHash((stringifyTOML(this.index)), 'sha256') as string
},
'versions': {
'minecraft': '1.21.11' // TODO get latest minecraft version
}
}
this.files = {}
}
// load modpack, rebuilding index
async refresh({ pack_file_name, pack_directory_handle, useHashes = false }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle, useHashes?: boolean
}) {
this.init()
pack_directory_handle = pack_directory_handle || this.pack_directory_handle
pack_file_name = pack_file_name || this.pack_file_name
const textDecoder = new TextDecoder()
const pack_file_handle = await pack_directory_handle.getFileHandle(pack_file_name)
{
const file = await pack_file_handle.getFile()
const text = await file.text()
this.pack = parseTOML(text)
}
{
useHashes = useHashes || !this.pack.options || !this.pack.options['no-internal-hashes']
this.index = useHashes ? { 'hash-format': this.pack.index['hash-format'] } : {}
const { dirname, basename } = splitPath(this.pack.index.file)
const index_directory_handle = await getDirectoryHandle(pack_directory_handle, dirname)
const index_file_handle = await index_directory_handle.getFileHandle(basename)
const directories = [index_directory_handle]
while (directories.length !== 0) {
await forAsync(directories.pop().values(), async handle => {
if (handle.kind === 'directory') return directories.push(handle)
if (handle.isSameEntry(index_file_handle) || handle.isSameEntry(pack_file_handle)) return
const file = await handle.getFile()
const bytes = await file.bytes()
const path = (await index_directory_handle.resolve(handle)).join('/')
const isMetaFile = handle.name.endsWith('.pw.toml')
this.index.files.push({
'file': path,
'metafile': isMetaFile,
'hash': useHashes ? await getHash(bytes, this.index['hash-format']) as string : undefined
})
if (isMetaFile) this.files[path] = parseTOML(textDecoder.decode(bytes))
})
}
}
}
// load modpack from storage
async load({ pack_file_name, pack_directory_handle }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle
}) {
this.init()
pack_file_name = pack_file_name || this.pack_file_name
pack_directory_handle = pack_directory_handle || this.pack_directory_handle
const textDecoder = new TextDecoder()
{
const pack_file_handle = await pack_directory_handle.getFileHandle(pack_file_name)
const file = await pack_file_handle.getFile()
const text = await file.text()
this.pack = parseTOML(text)
}
{
const { dirname, basename } = splitPath(this.pack.index.file)
const index_directory_handle = await getDirectoryHandle(pack_directory_handle, dirname)
const index_file_handle = await index_directory_handle.getFileHandle(basename)
const file = await index_file_handle.getFile()
const bytes = await file.bytes()
if (
this.pack.options &&
this.pack.options['no-internal-hashes'] &&
await getHash(bytes, this.pack.index['hash-format']) !== this.pack.index.hash
) throw new Error('hash validation failed on ' + file.name)
const text = textDecoder.decode(bytes)
this.index = parseTOML(text)
await forAsync(this.index.files, async entry => {
if (!entry.metafile) return
const file_handle = await getFileHandle(index_directory_handle, entry.file)
const file = await file_handle.getFile()
const bytes = await file.bytes()
if (
this.pack.options &&
this.pack.options['no-internal-hashes'] &&
await getHash(bytes, entry['hash-format']) !== entry.hash
) throw new Error('hash validation failed on ' + file.name)
const text = textDecoder.decode(bytes)
this.files[entry.file] = parseTOML(text)
})
}
}
// save modpack to storage
async save({ pack_file_name, pack_directory_handle }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle
}) {
pack_file_name = pack_file_name || this.pack_file_name
pack_directory_handle = pack_directory_handle || this.pack_directory_handle
{
const pack_file_handle = await pack_directory_handle.getFileHandle(pack_file_name, { create: true })
const writeable = await pack_file_handle.createWritable()
await writeable.write(stringifyTOML(this.pack))
await writeable.close()
}
{
const { dirname, basename } = splitPath(this.pack.index.file)
const index_directory_handle = await getDirectoryHandle(pack_directory_handle, dirname, { create: true })
const index_file_handle = await index_directory_handle.getFileHandle(basename)
const writeable = await index_file_handle.createWritable()
writeable.write(stringifyTOML(this.index))
writeable.close()
await forAsync(Object.entries(this.files), async ([path, mod]) => {
const mod_file_handle = await getFileHandle(index_directory_handle, path, { create: true })
const writeable = await mod_file_handle.createWritable()
await writeable.write(stringifyTOML(mod))
await writeable.close()
})
}
}
// find and replace files availible on curseforge
async cfDetect({ pack_file_name, pack_directory_handle, api_key, size_min = 4096 }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle,
api_key: string, size_min?: number
}) {
pack_file_name = pack_file_name || this.pack_file_name
pack_directory_handle = pack_directory_handle || this.pack_directory_handle
const { dirname, basename } = splitPath(this.pack.index.file)
const index_directory_handle = await getDirectoryHandle(pack_directory_handle, dirname)
const workerlessPool = new WorkerlessPool()
const file_hash_map = new Map<number, Path>()
await forAsync(this.index.files, async indexEntry => {
if (indexEntry.metafile) return
const file_handle = await getFileHandle(index_directory_handle, indexEntry.file)
const file = await file_handle.getFile()
if (file.size < size_min) return
const bytes = file.bytes()
const hash = Number(await workerlessPool.run(getHash, bytes, 'murmur2'))
})
workerlessPool.terminate()
console.log('requesting matches from curseforge')
const response = await new CFV2Client({ apiKey: api_key }).getFingerprintMatches({ 'fingerprints': Array.from(file_hash_map.keys()) })
const textEncoder = new TextEncoder()
let resultCount = 0
await forAsync(Object.values(response.data.data.exactMatches), async function (match) {
let file_path = file_hash_map.get(match.file.fileFingerprint)
if (!file_path) {
for (const module of match.file.modules) {
file_path = file_hash_map.get(module.fingerprint)
}
if (file_path) {
console.debug("skipping module match, this is probably a false positive", file_path, match.file)
} else {
console.debug("curseforge returned a result we didn't ask for, skipping", match.file)
}
return
}
const { dirname: file_dir_name, basename: file_name } = splitPath(file_path)
for (const hash of match.file.hashes) {
if (hash.algo === 1) {
const mod: PackFileMetaData = {
"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
}
}
}
const new_file_path = file_dir_name + '/' + encodeURI(match.file.displayName.toLowerCase().replaceAll(' ', '-') + '.pw.toml')
const new_file_data = textEncoder.encode(stringifyTOML(mod))
const new_file_hash = (await getHash(new_file_data, this.index['hash-format'])).toString()
await getFileHandle(index_directory_handle, new_file_path, { 'create': true })
.then(handle => handle.createWritable())
.then(stream => {
stream.write(new_file_data)
stream.close()
this.index.files.push({
"file": new_file_path,
"hash": new_file_hash.toString(),
"metafile": true
})
})
await removeEntry(index_directory_handle, file_path)
.then(() => {
for (let i = 0; i < this.index.files.length; i++) {
if (this.index.files[i].file != file_path) continue
this.index.files.splice(i, 1)
return
}
throw new Error("can't find removed file in index")
})
resultCount++
break
}
}
})
const index_file_data = textEncoder.encode(stringifyTOML(this.index))
const index_file_hash = await getHash(index_file_data, this.pack.index['hash-format'])
const index_file_handle = await getFileHandle(index_directory_handle, basename)
const pack_file_handle = await getFileHandle(pack_directory_handle, this.pack_file_name)
await index_file_handle.createWritable()
.then(async stream => {
await stream.write(index_file_data)
await stream.close()
this.pack.index.hash = index_file_hash.toString()
return pack_file_handle.createWritable()
}).then(async stream => {
await stream.write(stringifyTOML(this.pack))
await stream.close()
})
console.log(`found ${resultCount} matching files`)
}
// detect and replace files availible on modrinth
async mrDetect({ pack_file_name, pack_directory_handle, api_key, size_min = 4096 }: {
pack_file_name?: string, pack_directory_handle?: FileSystemDirectoryHandle,
api_key: string, size_min?: number
}) {
pack_file_name = pack_file_name || this.pack_file_name
pack_directory_handle = pack_directory_handle || this.pack_directory_handle
const { dirname, basename } = splitPath(this.pack.index.file)
const index_directory_handle = await getDirectoryHandle(pack_directory_handle, dirname)
const file_hash_map = new Map<Hash, Path>()
console.log('hashing files')
await forAsync(this.index.files, async function (entry) {
if (entry.metafile) return
const file = await getFileHandle(index_directory_handle, entry.file)
.then(handle => handle.getFile())
if (file.size < size_min) return
const file_data = await file.bytes()
const hash = await getHash(file_data, "sha1") as string
file_hash_map.set(hash, entry.file)
})
console.log('requesting matches from modrinth')
const matches = await modrinth.getLatestVersionsFromHashes({
'auth': api_key,
'body': {
"algorithm": "sha1",
"hashes": Array.from(file_hash_map.keys()),
"game_versions": [],
"loaders": []
}
})
const version_matches = new Map<string, modrinth.Version>()
for (const match of Object.values(matches.data)) version_matches.set(match.project_id, match)
const projects = await modrinth.getProjects({
'auth': api_key,
query: {
ids: JSON.stringify(Array.from(version_matches.keys()))
}
})
const textEncoder = new TextEncoder()
let resultCount = 0
await forAsync(projects.data, async function (project) {
const version = version_matches.get(project.id)
if (!version) {
console.debug("modrinth returned a project result we didn't ask for, skipping", project)
return
}
const file_path = file_hash_map.get(version.files[0].hashes.sha1)
if (!file_path) {
console.debug("modrinth returned a file result we didn't ask for, skipping", version)
return
}
let side: Side = "both"
if (project.client_side === "unsupported") side = "server"
if (project.server_side === "unsupported") side = "client"
const mod: PackFileMetaData = {
"name": project.title,
"filename": version.files[0].filename,
"side": side,
"download": {
"hash-format": "sha1",
"hash": version.files[0].hashes.sha1,
"url": version.files[0].url
},
"update": {
"modrinth": {
"version": version.id,
"mod-id": version.project_id
}
}
}
const new_file_path = splitPath(file_path).dirname + '/' + encodeURI(project.title.toLowerCase().replaceAll(' ', '-') + '.pw.toml')
const new_file_data = textEncoder.encode(stringifyTOML(mod))
const new_file_hash = await getHash(new_file_data, this.index['hash-format'])
await getFileHandle(index_directory_handle, new_file_path, { 'create': true })
.then(handle => handle.createWritable())
.then(stream => {
stream.write(new_file_data)
stream.close()
this.index.files.push({
"file": new_file_path,
"hash": new_file_hash,
"metafile": true
})
})
await removeEntry(index_directory_handle, file_path)
.then(() => {
for (let i = 0; i < this.index.files.length; i++) {
if (this.index.files[i].file != file_path) continue
this.index.files.splice(i, 1)
return
}
throw new Error("can't find removed file in index")
})
resultCount++
})
const index_file_data = textEncoder.encode(stringifyTOML(this.index))
const index_file_hash = await getHash(index_file_data, this.pack.index['hash-format'])
const index_file_handle = await getFileHandle(index_directory_handle, basename)
const pack_file_handle = await getFileHandle(pack_directory_handle, pack_file_name)
await index_file_handle.createWritable()
.then(async stream => {
await stream.write(index_file_data)
await stream.close()
this.pack.index.hash = index_file_hash.toString()
return pack_file_handle.createWritable()
}).then(async stream => {
await stream.write(stringifyTOML(this.pack))
await stream.close()
})
console.log(`found ${resultCount} matching files`)
}
// cache download urls for curseforge files
async cfUrl({ api_key }: { api_key: string }) {
const mod_id_paths: Map<number,Path>=new Map()
for (const [path, mod] of Object.entries(this.files)) {
if (mod.download.mode !== 'metadata:curseforge') continue
mod_id_paths.set(Number(mod.update.curseforge['file-id']),path)
}
console.log(`requesting ${mod_id_paths.size} download urls`)
const result = await new CFV2Client({ 'apiKey': api_key }).getFiles({ 'fileIds': Array.from(mod_id_paths.keys()) })
if (result.statusCode != 200) {
throw new Error(result.message)
}
const files = result.data.data
let blockedFileCount=0
for (const file of files) {
if (!file.downloadUrl) {
console.warn('third party downloads disabled for ' + file.displayName)
blockedFileCount++
continue
}
const path = mod_id_paths.get(file.id)!
delete this.files[path].download.mode
this.files[path].download.url = file.downloadUrl
}
console.log(`found ${files.length-blockedFileCount} download urls`)
}
export interface PackwizPack {
packOrigin: FileSystemDirectoryHandle | Url
packFileName: Path
packMetaData: PackMetaData
packIndex: PackIndex
packMetaFiles: { [key: Path]: PackFileMetaData | undefined }
}
/**
* find curseforge files that are availible on modrinth and merge their metadata
* @param pack_url url or path to pack.toml
* @param mrApiKey modrinth api key
*/
export async function mrMerge(pack_url: string, mrApiKey?: string): Promise<Result> {
console.log('loading pack')
const pack = await fetch_toml(pack_url) as PackMetaData
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 PackIndex
const index_dir = dirname(index_url)
if (!index.files) {
console.warn(`${index_url} has no files indexed`)
return {}
export async function loadPack(origin: FileSystemDirectoryHandle | Url, packFileName = 'pack.toml') {
const packMetaData:PackMetaData = await getBytes(origin, packFileName).then(bytes => parseTOML(bytes.toString()))
const packIndex: PackIndex = await getBytes(origin, packMetaData.index.file).then(bytes => parseTOML(bytes.toString()))
const {dirname:indexDirPath}=splitPath(packMetaData.index.file)
for (const entry of packIndex.files) {
// TODO
}
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 PackFileMetaData
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<string, any>()
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: PackFileMetaData | 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
}
+2 -5
View File
@@ -8,12 +8,9 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "../../dist",
"types": [],
"types": ["@sugoidogo/node-web-globals"],
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": [
"packwiz.ts"
]
}
}
+309
View File
@@ -0,0 +1,309 @@
/**
* 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 PackMetaData {
/**
* 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"?: Path
/** deprecated; aliassed to meta-folder */
"mods-folder"?: Path
/**
* 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)
* @see {@link Path}
*/
"meta-folder-base"?: Path
/**
* 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)
* @see {@link Path}
*/
"datapack-folder"?: Path
}
}
/**
* The index file of the modpack,
* storing references to every file to be downloaded in the pack.
*/
export interface PackIndex {
/** 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"?: Path
/**
* 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 PackFileMetaData {
/**
* 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
}
}
}