diff --git a/docs/horizr.md b/docs/horizr.md deleted file mode 100644 index 8c97364..0000000 --- a/docs/horizr.md +++ /dev/null @@ -1,40 +0,0 @@ -# horizr - -## CLI -**Note:** Most commands are interactive and therefore not suitable for usage in scripts. - -Commands expecting a `MOD_ID` will reuse the ID from the last command if none is provided. - -All commands (aside from `init`) expect to find a `horizr.json` file in their current working directory. - -### init -Initialize a new pack in the current working directory. - -### info -Print information about the pack. - -### search NAME -Search for mods by `NAME` and allow selecting one from the results. - -Selecting a mod has the same effect as the `mod MOD_ID` subcommand. - -### add MOD_ID -Adds the mod to the pack. - -### remove MOD_ID -Remove the mod from the pack. - -### refresh -Fetches information about updates. - -### update MOD_ID -Update the mod to a newer version. - -### mod MOD_ID -Print information about the mod. - -### export modrinth -Export the pack into `./NAME.mrpack` for Modrinth. - -### export packwiz -Export the pack into the `./packwiz` directory for packwiz. diff --git a/package.json b/package.json index 135eddd..a488553 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dedent": "^0.7.0", "enquirer": "^2.3.6", "env-paths": "^3.0.0", + "fast-glob": "^3.2.11", "figures": "^5.0.0", "find-up": "^6.3.0", "fs-extra": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e745c2..496b376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ specifiers: del-cli: ^5.0.0 enquirer: ^2.3.6 env-paths: ^3.0.0 + fast-glob: ^3.2.11 figures: ^5.0.0 find-up: ^6.3.0 fs-extra: ^10.1.0 @@ -47,6 +48,7 @@ dependencies: dedent: 0.7.0 enquirer: 2.3.6 env-paths: 3.0.0 + fast-glob: 3.2.11 figures: 5.0.0 find-up: 6.3.0 fs-extra: 10.1.0 @@ -141,12 +143,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat/2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk/1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -154,7 +154,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.13.0 - dev: true /@root/walk/1.1.0: resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==} @@ -323,7 +322,6 @@ packages: engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /buffer-crc32/0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -801,7 +799,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-url-parser/1.1.3: resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} @@ -813,7 +810,6 @@ packages: resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} dependencies: reusify: 1.0.4 - dev: true /figures/5.0.0: resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} @@ -828,7 +824,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-up/5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} @@ -905,7 +900,6 @@ packages: engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob/7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -1032,14 +1026,12 @@ packages: /is-extglob/2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-glob/4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-interactive/2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} @@ -1049,7 +1041,6 @@ packages: /is-number/7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-path-cwd/3.0.0: resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==} @@ -1214,7 +1205,6 @@ packages: /merge2/1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch/4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -1222,7 +1212,6 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true /mime-db/1.33.0: resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} @@ -1422,7 +1411,6 @@ packages: /picomatch/2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pump/3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} @@ -1437,7 +1425,6 @@ packages: /queue-microtask/1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /quick-lru/5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} @@ -1505,7 +1492,6 @@ packages: /reusify/1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf/3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -1518,7 +1504,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /s-ago/2.2.0: resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==} @@ -1632,7 +1617,6 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - dev: true /toml/3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} diff --git a/src/commands/info.ts b/src/commands/info.ts new file mode 100644 index 0000000..569670b --- /dev/null +++ b/src/commands/info.ts @@ -0,0 +1,24 @@ +import { Command } from "commander" +import { output } from "../utils/output.js" +import dedent from "dedent" +import kleur from "kleur" +import { default as wrapAnsi } from "wrap-ansi" +import { usePack } from "../pack.js" + +export const infoCommand = new Command("info") + .description("Print information about the pack.") + .action(async () => { + const pack = await usePack() + const { meta } = pack.manifest + + output.println(dedent` + ${kleur.bold(meta.name)} (${meta.version}) + ${meta.description === undefined ? "" : wrapAnsi(meta.description, process.stdout.columns) + "\n"}\ + + Authors: ${kleur.yellow(meta.authors.join(", "))} + License: ${kleur.yellow(meta.license.toUpperCase())} + Mods: ${kleur.yellow(pack.metaFiles.filter(metaFile => metaFile.isMod).length.toString())} + + Minecraft version: ${kleur.yellow(pack.manifest.versions.minecraft)} + `) + }) diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..654721e --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,82 @@ +import { Command } from "commander" +import { envPaths } from "../utils/path.js" +import fs from "fs-extra" +import { output } from "../utils/output.js" +import kleur from "kleur" +import { fetchFabricMinecraftVersions, fetchFabricVersions } from "../fabricApi.js" +import enquirer from "enquirer" +import { PACK_MANIFEST_FILE_NAME, PACK_MANIFEST_FORMAT_VERSION, PackManifest } from "../files.js" +import pathModule from "path" + +export const initCommand = new Command("init") + .argument("") + .description("Initialize a new pack in the directory.") + .action(async pathString => { + const path = envPaths.cwd.resolveAny(pathString) + const manifestFilePath = path.resolve(PACK_MANIFEST_FILE_NAME) + + if (await fs.pathExists(manifestFilePath.toString())) output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} already exists in the directory.`) + + await fs.mkdirp(path.toString()) + const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions") + + const answers: any = await enquirer.prompt([ + { + name: "name", + type: "input", + message: "Name", + validate: answer => answer.length === 0 ? "An answer is required." : true + }, + { + name: "authors", + type: "input", + message: "Authors (comma-separated)", + validate: answer => answer.length === 0 ? "An answer is required." : true + }, + { + name: "description", + type: "text", + message: "Description" + }, + { + name: "license", + type: "text", + message: "License (SPDX-ID)", + validate: answer => answer.length === 0 ? "An answer is required." : true + }, + { + name: "minecraftVersion", + type: "autocomplete", + message: "Minecraft version", + choices: minecraftVersions.map(version => ({ + name: version, + value: version + })), + // @ts-expect-error + limit: 10, + validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list." + } + ]) + + const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0] + + const file: PackManifest = { + formatVersion: PACK_MANIFEST_FORMAT_VERSION, + meta: { + name: answers.name, + version: "1.0.0", + description: answers.description === "" ? undefined : answers.description, + authors: (answers.authors as string).split(", ").map(a => a.trim()), + license: answers.license + }, + versions: { + minecraft: answers.minecraftVersion, + fabric: fabricVersion + } + } + + await fs.writeJson(manifestFilePath.toString(), file, { spaces: 2 }) + await fs.writeFile(path.resolve(".gitignore").toString(), "/generated/") + + output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(pathModule.normalize(pathString))}`)) + }) diff --git a/src/commands/modrinth/activate.ts b/src/commands/modrinth/activate.ts new file mode 100644 index 0000000..6b7fe59 --- /dev/null +++ b/src/commands/modrinth/activate.ts @@ -0,0 +1,82 @@ +import { Command } from "commander" +import { sideOption } from "../../utils/options.js" +import { + findMetaFileForModrinthMod, + getMetaFileContentVersionForModrinth, + getSideOfModrinthMod, + isModrinthVersionCompatible, + resolveModrinthCode, + sortModrinthVersionsByPreference +} from "../../modrinth/index.js" +import { output } from "../../utils/output.js" +import { modrinthApi } from "../../modrinth/api.js" +import { Side, usePack } from "../../pack.js" +import kleur from "kleur" +import { META_FILE_EXTENSION, metaFileContentSchema, writeJsonFile } from "../../files.js" +import fs from "fs-extra" +import enquirer from "enquirer" + +export const activateCommand = new Command("activate") + .argument("") + .alias("a") + .option("-s, --side ", "The side of the mod", sideOption, null) + .option("-y, --yes", "Skip confirmations.") + .action(async (code, options) => { + const pack = await usePack() + const resolvedCode = await output.withLoading(resolveModrinthCode(code), "Resolving code") + const modrinthMod = resolvedCode.modrinthMod + let modrinthVersion = resolvedCode.modrinthVersion + + const existingMetaFile = findMetaFileForModrinthMod(pack.metaFiles, modrinthMod) + if (existingMetaFile !== null) { + output.println(`The mod is already active: ${kleur.yellow(existingMetaFile.relativePath.toString())} ${kleur.blue(existingMetaFile.content.version.name)}`) + + const confirmed = options.yes || (await enquirer.prompt({ + type: "confirm", + name: "confirmed", + message: "Do you want to continue?", + initial: false + }) as any).confirmed + + if (!confirmed) process.exit() + } + + let side: Side + const specifiedSide = getSideOfModrinthMod(modrinthMod) + const sideOverride = options.side + + if (sideOverride === null) side = specifiedSide + else { + if (specifiedSide !== "universal" && specifiedSide !== sideOverride) return output.failAndExit(`Mod is incompatible with specified side: ${kleur.yellow(sideOverride)}`) + else side = sideOverride + } + + if (modrinthVersion === null) { + const versions = await output.withLoading(modrinthApi.listVersions(modrinthMod.id, pack.manifest.versions.minecraft), "Fetching versions") + if (versions.length === 0) return output.failAndExit("No compatible version available.") + + const sortedVersions = sortModrinthVersionsByPreference(versions) + modrinthVersion = sortedVersions[0] + } else { + if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.") + } + + const absolutePath = pack.paths.source.resolve(side, "mods", `${modrinthMod.slug}.${META_FILE_EXTENSION}`) + const relativePath = pack.paths.source.relativeTo(absolutePath) + + await fs.mkdirp(absolutePath.parent().toString()) + await writeJsonFile(absolutePath, metaFileContentSchema, { + enabled: true, + version: getMetaFileContentVersionForModrinth(modrinthVersion), + source: { + type: "modrinth", + versionId: modrinthVersion.id, + modId: modrinthVersion.projectId, + ignoreUpdates: false + } + }) + + await pack.registerCreatedSourceFile(relativePath) + + output.println(kleur.green(`Successfully wrote ${kleur.yellow(relativePath.toString())}`)) + }) diff --git a/src/commands/modrinth/index.ts b/src/commands/modrinth/index.ts new file mode 100644 index 0000000..723c1dc --- /dev/null +++ b/src/commands/modrinth/index.ts @@ -0,0 +1,16 @@ +import { Command } from "commander" +import { activateCommand } from "./activate.js" +import dedent from "dedent" +import kleur from "kleur" + +export const modrinthCommand = new Command("modrinth") + .alias("mr") + .addCommand(activateCommand) + .addHelpText("after", dedent` + ${kleur.yellow("")} may be one of the following: + - URL or slug of a Modrinth mod (${kleur.yellow("https://modrinth.com/mod/sodium")} or ${kleur.yellow("sodium")}) + - URL of a Modrinth mod version (${kleur.yellow("https://modrinth.com/mod/sodium/version/mc1.19-0.4.2")}) + - slug of a Modrinth mod and a version with a ${kleur.yellow("@")} in between (${kleur.yellow("sodium:mc1.19-0.4.2")}) + - Modrinth project ID (${kleur.yellow("AANobbMI")} for Sodium) + - Modrinth version ID, prefixed with ${kleur.yellow("@")} (${kleur.yellow("@Yp8wLY1P")} for Sodium mc1.19-0.4.2) + `) diff --git a/src/commands/packwiz.ts b/src/commands/packwiz.ts deleted file mode 100644 index cf571e6..0000000 --- a/src/commands/packwiz.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Command } from "commander" -import { Mod, usePack } from "../pack.js" -import fs from "fs-extra" -import dedent from "dedent" -import kleur from "kleur" -import { getLANAddress, getSha512HexHash, httpServeDirectory, optionParsePositiveInteger } from "../utils.js" -import { output } from "../output.js" -import { Visitor, walk } from "@root/walk" -import { Path } from "../path.js" -import toml from "toml" -import { addModrinthMod } from "../modrinth/utils.js" -import { modrinthApi } from "../modrinth/api.js" - -const packwizCommand = new Command("packwiz") - .alias("pw") - -packwizCommand.command("import ") - .description("Import the mods of a packwiz pack. Overrides are ignored.") - .addHelpText("after", kleur.red("This command should only be used in newly created packs. Otherwise, the behaviour is undefined.")) - .action(async path => { - const packDirectoryPath = Path.create(path) - const modsDirectoryPath = packDirectoryPath.resolve("mods") - if (!await fs.pathExists(modsDirectoryPath.toString())) output.failAndExit(`The pack does not contain a ${kleur.yellow("mods")} directory.`) - - const modFileNames = (await fs.readdir(modsDirectoryPath.toString(), { withFileTypes: true })) - .filter(dirent => dirent.isFile() && dirent.name.endsWith(".toml")) - .map(dirent => dirent.name) - - let index = 0 - for (const modFileName of modFileNames) { - const content = toml.parse(await fs.readFile(modsDirectoryPath.resolve(modFileName).toString(), "utf-8")) - const modrinthVersionId = content.update?.modrinth?.version - - if (modrinthVersionId === undefined) output.warn(`${kleur.yellow(modFileName)} has no Modrinth version ID associated. It will not be imported.`) - else { - const modrinthVersion = (await output.withLoading(modrinthApi.getVersion(modrinthVersionId), "Fetching version information"))! - const modrinthMod = (await output.withLoading(modrinthApi.getMod(modrinthVersion.projectId), "Fetching mod information"))! - - await addModrinthMod(modrinthMod, modrinthVersion, content.side?.replace("both", "client-server")) - } - - output.println(`${kleur.yellow(modFileName)} ${kleur.green("was imported.")} ${kleur.gray(`(${index}/${modFileNames.length})`)}`) - index++ - } - - output.println(`${kleur.yellow(modFileNames.length)} ${kleur.green("mods were imported.")}`) - }) - -packwizCommand.command("serve") - .description("Start an HTTP server in the packwiz directory.") - .option("-p, --port ", "The port of the HTTP server.", optionParsePositiveInteger, 8000) - .option("-e, --expose", "Expose the HTTP server on all interfaces.") - .action(async options => { - const pack = await usePack() - const directoryPath = pack.paths.generated.resolve("packwiz") - if (!(await fs.pathExists(directoryPath.toString()))) - output.failAndExit(`The ${kleur.yellow("packwiz")} directory does not exist. Generate it by running ${kleur.yellow("horizr packwiz export")}.`) - - await serveExportOutput(directoryPath, options.port, options.expose) - }) - -// packwizCommand.command("dev") -// .description("serve + export with hot-reloading.") -// .option("-s, --server", "Use server overrides instead of client overrides.") -// .option("-p, --port ", "The port of the HTTP server.", optionParsePositiveInteger, 8000) -// .option("-e, --expose", "Expose the HTTP server on all interfaces.") -// .action(async options => { -// -// }) - -packwizCommand.command("export") - .description("Generate a packwiz pack in the packwiz directory.") - .option("-s, --server", "Use server overrides instead of client overrides.") - .action(async options => { - await runExport(options.server) - }) - -async function runExport(forServer: boolean) { - const pack = await usePack() - - const loader = output.startLoading("Generating") - - const outputDirectoryPath = pack.paths.generated.resolve("packwiz") - await fs.remove(outputDirectoryPath.toString()) - await fs.mkdirp(outputDirectoryPath.resolve("mods").toString()) - - const indexedFiles: IndexedFile[] = [] - await indexMods(indexedFiles, outputDirectoryPath) - - loader.setText(`Copying and hashing ${forServer ? "server" : "client"} overrides`) - await copyOverrides(indexedFiles, outputDirectoryPath, forServer) - - loader.setText(`Writing ${kleur.yellow("index.toml")}`) - - await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath) - - loader.stop() - output.println(kleur.green("Generated packwiz pack")) - - return { - indexedFiles - } -} - -interface IndexedFile { - path: string - sha512HashHex: string - isMeta: boolean -} - -async function writeAndIndexModMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: Path, mod: Mod) { - const content = dedent` - name = ${JSON.stringify(mod.modFile.name)} - filename = ${JSON.stringify(mod.modFile.file.name)} - side = "${mod.modFile.side.replace("client-server", "both")}" - - [download] - hash-format = "sha512" - hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)} - url = ${JSON.stringify(mod.modFile.file.downloadUrl)} - ` - - const path = outputDirectoryPath.resolve(`mods/${mod.id}.toml`) - await fs.writeFile(path.toString(), content) - - indexedFiles.push({ - path: `mods/${mod.id}.toml`, - isMeta: true, - sha512HashHex: await getSha512HexHash(content) - }) -} - -async function indexMods(indexedFiles: IndexedFile[], outputDirectoryPath: Path, warn: boolean = true) { - const pack = await usePack() - - for (const mod of pack.mods) { - if (warn && !mod.modFile.enabled) output.warn(`${kleur.yellow(mod.modFile.name)} is disabled and will not be included.`) - - await output.withLoading( - writeAndIndexModMetaFile(indexedFiles, outputDirectoryPath, mod), - `Generating ${kleur.yellow(mod.id + ".toml")} (${indexedFiles.length + 1}/${pack.mods.length})` - ) - } -} - -async function copyOverrides(indexedFiles: IndexedFile[], outputDirectoryPath:Path, forServer: boolean) { - const pack = await usePack() - - const createVisitor = (overridesDirectoryPath: Path): Visitor => async (error, path, dirent) => { - const relativePath = overridesDirectoryPath.relative(path) - - if (error) output.warn(`${kleur.yellow(relativePath.toString())}: ${error.message}`) - else { - if (dirent.name.startsWith(".")) return false - if (dirent.isFile()) { - const outputPath = outputDirectoryPath.resolve(relativePath) - await fs.mkdirp(outputPath.getParent().toString()) - await fs.copy(path, outputPath.toString()) - - indexedFiles.push({ - path: relativePath.toString(), - isMeta: false, - sha512HashHex: await getSha512HexHash(await fs.readFile(overridesDirectoryPath.resolve(path).toString())) - }) - } - } - } - - const specificOverridesDirectoryPath = pack.paths.overrides[forServer ? "server" : "client"] - const universalOverridesDirectoryPath = pack.paths.overrides["client-server"] - - if (await fs.pathExists(specificOverridesDirectoryPath.toString())) await walk(specificOverridesDirectoryPath.toString(), createVisitor(specificOverridesDirectoryPath)) - if (await fs.pathExists(universalOverridesDirectoryPath.toString())) await walk(universalOverridesDirectoryPath.toString(), createVisitor(universalOverridesDirectoryPath)) -} - -async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: Path) { - const pack = await usePack() - - const index = dedent` - hash-format = "sha512" - - ${indexedFiles.map(file => dedent` - [[files]] - file = ${JSON.stringify(file.path)} - hash = "${file.sha512HashHex}" - metafile = ${file.isMeta} - `).join("\n\n")} - ` - - await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index) - const indexHash = await getSha512HexHash(index) - - await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent` - name = ${JSON.stringify(pack.horizrFile.meta.name)} - authors = ${JSON.stringify(pack.horizrFile.meta.authors.join(", "))}\ - ${pack.horizrFile.meta.description === undefined ? "" : "\n" + `description = ${JSON.stringify(pack.horizrFile.meta.description)}`} - pack-format = "packwiz:1.0.0" - - [versions] - minecraft = ${JSON.stringify(pack.horizrFile.versions.minecraft)} - fabric = ${JSON.stringify(pack.horizrFile.versions.fabric)} - - [index] - file = "index.toml" - hash-format = "sha512" - hash = "${indexHash}" - `) -} - -async function serveExportOutput(path: Path, port: number, expose: boolean) { - const lanAddress = await getLANAddress() - const localAddress = `http://localhost:${port}/pack.toml` - - await new Promise(resolve => { - httpServeDirectory(path, port, expose, () => { - if (expose) { - output.println(dedent` - ${kleur.green("Serving at")} - Local: ${kleur.yellow(localAddress)} - Network: ${kleur.yellow(`http://${lanAddress}:${port}/pack.toml`)} - `) - } else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`) - - resolve() - }) - }) -} - -export { packwizCommand} diff --git a/src/commands/packwiz/export.ts b/src/commands/packwiz/export.ts new file mode 100644 index 0000000..f7a46c2 --- /dev/null +++ b/src/commands/packwiz/export.ts @@ -0,0 +1,42 @@ +import { Command } from "commander" +import kleur from "kleur" +import { Side, usePack } from "../../pack.js" +import { output } from "../../utils/output.js" +import fs from "fs-extra" +import { IndexedFile, PACKWIZ_EXPORT_DIRECTORY_NAME, writeAndIndexMetaFile, writeAndIndexStaticSourceFile, writeIndexAndPackManifest } from "../../packwiz/exporting.js" + +export const exportCommand = new Command("export") + .description("Export a packwiz pack.") + .option("-s, --server", "Use server overrides instead of client overrides. Only applies to static files.") + .action(async (path, options) => { + const pack = await usePack() + const side: Side = options.server ? "server" : "client" + const loader = output.startLoading("Exporting") + + const outputDirectoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME) + await fs.remove(outputDirectoryPath.toString()) + + const indexedFiles: IndexedFile[] = [] + + let i = 0 + for (const metaFile of pack.metaFiles) { + i++ + loader.setText(`Exporting ${kleur.yellow(metaFile.getDisplayString())} (${i}/${pack.metaFiles.length})`) + await writeAndIndexMetaFile(indexedFiles, outputDirectoryPath, metaFile) + } + + i = 0 + for (const staticSourceFile of pack.staticSourceFiles) { + i++ + if (staticSourceFile.side !== "universal" && staticSourceFile.side !== side) continue + + loader.setText(`Exporting ${kleur.yellow(staticSourceFile.relativePath.toString())} (${i}/${pack.metaFiles.length})`) + await writeAndIndexStaticSourceFile(indexedFiles, outputDirectoryPath, staticSourceFile) + } + + loader.setText(`Creating ${kleur.yellow("index.toml")} and ${kleur.yellow("pack.toml")}`) + await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath) + loader.stop() + + output.println(kleur.green("Generated packwiz pack")) + }) diff --git a/src/commands/packwiz/import.ts b/src/commands/packwiz/import.ts new file mode 100644 index 0000000..884e50e --- /dev/null +++ b/src/commands/packwiz/import.ts @@ -0,0 +1,22 @@ +import { Command } from "commander" +import kleur from "kleur" +import { envPaths } from "../../utils/path.js" +import fs from "fs-extra" +import { output } from "../../utils/output.js" +import * as toml from "toml" + +export const importCommand = new Command("import") + .argument("") + .description("Import a packwiz pack.") + .addHelpText("after", kleur.red("Please create a backup of the pack before using this command.")) + .action(async path => { + const inputDirectoryPath = envPaths.cwd.resolveAny(path) + const packTomlPath = inputDirectoryPath.resolve("pack.toml") + + if (!await fs.pathExists(packTomlPath.toString())) + output.failAndExit(`${kleur.yellow(packTomlPath.toString())} does not exist.`) + + const packTomlContent = toml.parse(await fs.readFile(packTomlPath.toString(), "utf-8")) + + // TODO + }) diff --git a/src/commands/packwiz/index.ts b/src/commands/packwiz/index.ts new file mode 100644 index 0000000..7c71d1f --- /dev/null +++ b/src/commands/packwiz/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander" +// import { importCommand } from "./import.js" +import { serveCommand } from "./serve.js" +import { exportCommand } from "./export.js" + +export const packwizCommand = new Command("packwiz") + .alias("pw") + .addCommand(exportCommand) + // .addCommand(importCommand) + .addCommand(serveCommand) diff --git a/src/commands/packwiz/serve.ts b/src/commands/packwiz/serve.ts new file mode 100644 index 0000000..f25df66 --- /dev/null +++ b/src/commands/packwiz/serve.ts @@ -0,0 +1,24 @@ +import { Command } from "commander" +import kleur from "kleur" +import fs from "fs-extra" +import { output } from "../../utils/output.js" +import { positiveIntegerOption } from "../../utils/options.js" +import { usePack } from "../../pack.js" +import { PACKWIZ_EXPORT_DIRECTORY_NAME } from "../../packwiz/exporting.js" +import { httpServeDirectoryWithMessage } from "../../utils/http.js" + +export const serveCommand = new Command("serve") + .description("Start an HTTP server in the packwiz directory.") + .option("-p, --port ", "The port of the HTTP server.", positiveIntegerOption, 8000) + .option("-e, --expose", "Expose the HTTP server on all interfaces.") + .action(async options => { + const pack = await usePack() + const directoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME) + + if (!(await fs.pathExists(directoryPath.toString()))) + output.failAndExit(`The ${kleur.yellow(pack.paths.root.relativeTo(directoryPath).toString())} directory does not exist. ` + + `Generate it by running ${kleur.yellow("horizr packwiz export")}.` + ) + + await httpServeDirectoryWithMessage(directoryPath, options.port, options.expose) + }) diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..4a4c106 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,82 @@ +import { Command } from "commander" +import { output } from "../utils/output.js" +import kleur from "kleur" +import dedent from "dedent" +import figures from "figures" +import { ReleaseChannel, Update, usePack } from "../pack.js" +import { filterNulls, mapNotNull } from "../utils/collections.js" +import pLimit from "p-limit" +import { gtzIntegerOption } from "../utils/options.js" +import enquirer from "enquirer" + +export const updateCommand = new Command("update") + .argument("[path]") + .description("Check for updates of all meta files or apply a specific update.") + .option("-y, --yes", "Skip confirmations") + .option("-a, --alpha", "Allow alpha versions") + .option("-b, --beta", "Allow beta versions") + .option("-c, --concurrency", "Number of concurrent checks", gtzIntegerOption, 5) + .action(async (pathString, options) => { + const pack = await usePack() + const allowedReleaseChannels: ReleaseChannel[] = ["release"] + if (options.alpha) allowedReleaseChannels.push("alpha") + if (options.beta) allowedReleaseChannels.push("beta") + + if (pathString === undefined) { + const limit = pLimit(options.concurrency) + const updateFetches = mapNotNull(pack.metaFiles, metaFile => { + const { fetchUpdates } = metaFile + if (fetchUpdates === null) return null + else return limit(async () => { + const updates = await fetchUpdates(allowedReleaseChannels) + if (updates.length === 0) return null + else return updates[0] + }) + }) + + const updates = filterNulls( + await output.withLoading( + Promise.all(updateFetches), + `Fetching updates for ${kleur.yellow(updateFetches.length)} meta files` + ) + ) + + if (updates.length === 0) output.println(kleur.green("Everything up-to-date.")) + else { + const getChange = (update: Update) => `${kleur.red(update.of.content.version.name)} ${figures.arrowRight} ${kleur.green(update.versionString)}` + + output.println(dedent` + ${kleur.underline("Available updates")} + + ${updates.map(update => `- ${update.of.getDisplayString()} ${getChange(update)}`).join("\n")} + `) + } + } else { + const metaFile = pack.getMetaFileFromInput(pathString) + if (metaFile.fetchUpdates === null) return output.failAndExit(`${kleur.yellow(metaFile.relativePath.toString())} is not updatable.`) + + const updates = await metaFile.fetchUpdates(allowedReleaseChannels) + if (updates.length === 0) output.println(kleur.green("No updates available.")) + else { + output.println(kleur.bold("Changelogs") + "\n") + + for (let update of updates) { + output.println(kleur.underline(update.versionString)) + output.printlnWrapping((update.changelog ?? kleur.gray("not provided")) + "\n") + } + + const confirmed = options.yes || (await enquirer.prompt({ + type: "confirm", + name: "confirmed", + message: "Apply the update?" + }) as any).confirmed + + const update = updates[0] + + if (confirmed) { + await output.withLoading(update.apply(), "Updating") + output.println(kleur.green(`Successfully updated ${metaFile.getDisplayString()} to ${kleur.yellow(update.versionString)}.`)) + } + } + } + }) diff --git a/src/fabricApi.ts b/src/fabricApi.ts index 5d2993a..16cacca 100644 --- a/src/fabricApi.ts +++ b/src/fabricApi.ts @@ -1,4 +1,4 @@ -import { got } from "./utils.js" +import { got } from "./utils/http.js" export async function fetchFabricMinecraftVersions(): Promise { const versions = await got("https://meta.fabricmc.net/v1/versions/game").json() diff --git a/src/files.ts b/src/files.ts index 775bc91..c669230 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,57 +1,54 @@ -import { SafeParseError, z, ZodRawShape } from "zod" -import kleur from "kleur" -import fs from "fs-extra" -import * as process from "process" -import { dirname } from "path" +import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js" +import process from "process" import { findUp } from "find-up" -import { output } from "./output.js" -import { Path } from "./path.js" -import { Dirent } from "fs" -import { sides } from "./shared.js" +import { output } from "./utils/output.js" +import kleur from "kleur" +import { SafeParseError, z, ZodRawShape } from "zod" +import fs from "fs-extra" +import { dirname } from "path" +import fastGlob from "fast-glob" +import { sides } from "./pack.js" -export async function findPackDirectoryPath(): Promise { +export async function findPackDirectoryPath(): Promise { if (process.argv0.endsWith("/node")) { // run using pnpm - return Path.createAbsolute("./test-pack") + return envPaths.cwd.resolve("./test-pack") } else { - const parent = await findUp("horizr.json") - if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`) + const parent = await findUp(PACK_MANIFEST_FILE_NAME) + if (parent === undefined) return output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} could not be found in the current working directory or any parent.`) - return Path.createAbsolute(dirname(parent)) + return AbsolutePath.create(dirname(parent)) } } -export async function readJsonFileInPack>( - packPath: Path, - filePath: Path, - schema: S -): Promise | null> { +export async function writeJsonFile>(path: AbsolutePath, schema: S, data: z.input) { + await fs.mkdirp(path.parent().toString()) + await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 }) +} + +export async function readJsonFile>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise | null> { let data try { - data = await fs.readJson(packPath.resolve(filePath).toString()) + data = await fs.readJson(rootPath.resolve(specificPath).toString()) } catch (e: unknown) { - if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`) + if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(specificPath.toString())} does not contain valid JSON.`) else return null } const result = await schema.safeParseAsync(data) if (!result.success) { const error = (result as SafeParseError).error - return output.failAndExit(`${kleur.yellow(filePath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`) + return output.failAndExit(`${kleur.yellow(specificPath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`) } return result.data } -export async function writeJsonFileInPack>(packPath: Path, filePath: Path, schema: S, data: z.input) { - const absolutePath = packPath.resolve(filePath) - await fs.mkdirp(absolutePath.getParent().toString()) +export const PACK_MANIFEST_FORMAT_VERSION = 1 +export const PACK_MANIFEST_FILE_NAME = "horizr.json" - await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 }) -} - -const horizrFileSchema = z.object({ - formatVersion: z.string().or(z.number()), +export const horizrFileSchema = z.object({ + formatVersion: z.literal(PACK_MANIFEST_FORMAT_VERSION), meta: z.object({ name: z.string(), version: z.string(), @@ -65,75 +62,48 @@ const horizrFileSchema = z.object({ }) }) -export type HorizrFile = z.output -export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1 +export type PackManifest = z.output -export async function readHorizrFile(packPath: Path) { - const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema) - if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`) - if (data.formatVersion !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`) +export const META_FILE_EXTENSION = "hm.json" - return data -} - -const modFileModrinthSourceSchema = z.object({ +const metaFileModrinthSourceSchema = z.object({ type: z.literal("modrinth"), modId: z.string(), versionId: z.string() }) -export type ModFileModrinthSource = z.output +export type MetaFileModrinthSource = z.output -const modFileDataSchema = z.object({ - version: z.string(), +const metaFileContentVersionSchema = z.object({ name: z.string(), size: z.number().int().min(0).optional(), + fileName: z.string(), downloadUrl: z.string().url(), - hashes: z.object({ // Adopted from Modrinth + hashes: z.object({ sha1: z.string(), sha512: z.string() }) }) -export type ModFileData = z.output +export type MetaFileContentVersion = z.output -const modFileSchema = z.object({ - name: z.string(), +export const metaFileContentSchema = z.object({ + displayName: z.string().optional(), enabled: z.boolean().default(true), - ignoreUpdates: z.boolean().default(false), - side: z.enum(sides), comment: z.string().optional(), - file: modFileDataSchema, + version: metaFileContentVersionSchema, source: z.discriminatedUnion("type", [ - modFileModrinthSourceSchema, + metaFileModrinthSourceSchema, z.object({ type: z.literal("raw") }) - ]) + ]).and(z.object({ + ignoreUpdates: z.boolean().default(false) + })).optional() }) -export type ModFile = z.output +export type MetaFileContent = z.output -export async function readModFile(packPath: Path, modId: string): Promise { - return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema) -} - -export async function writeModFile(packPath: Path, modId: string, data: z.input): Promise { - await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data) -} - -export async function removeModFile(packPath: Path, modId: string): Promise { - await fs.remove(packPath.resolve("mods", `${modId}.json`).toString()) -} - -export async function readModIds(packPath: Path) { - const modsPath = packPath.resolve("mods") - await fs.mkdirp(modsPath.toString()) - const files = await fs.readdir(modsPath.toString(), { withFileTypes: true }) - - return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5)) -} - -export async function getOverrideDirents(overridesDirectoryPath: Path): Promise { - if (!await fs.pathExists(overridesDirectoryPath.toString())) return [] - - return await fs.readdir(overridesDirectoryPath.toString(), { withFileTypes: true }) -} +export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), { + cwd: sourceDirectoryPath.toString(), + followSymbolicLinks: false, + onlyFiles: true +}).then(paths => paths.map(path => RelativePath.create(path))) diff --git a/src/main.ts b/src/main.ts index 3b31164..47c47b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,197 +1,34 @@ import { Command } from "commander" -import kleur from "kleur" -import { usePack } from "./pack.js" -import loudRejection from "loud-rejection" -import { modrinthCommand } from "./commands/modrinth.js" -import { packwizCommand } from "./commands/packwiz.js" -import dedent from "dedent" -import { default as wrapAnsi } from "wrap-ansi" -import { CURRENT_HORIZR_FILE_FORMAT_VERSION, HorizrFile, removeModFile } from "./files.js" -import { output } from "./output.js" -import figures from "figures" -import { releaseChannelOrder } from "./shared.js" import fs from "fs-extra" -import { Path } from "./path.js" -import enquirer from "enquirer" -import { clearCache } from "./utils.js" -import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js" +import { AbsolutePath } from "./utils/path.js" +import { output } from "./utils/output.js" +import kleur from "kleur" +import loudRejection from "loud-rejection" +import { clearGotCache } from "./utils/http.js" +import { initCommand } from "./commands/init.js" +import { infoCommand } from "./commands/info.js" +import { updateCommand } from "./commands/update.js" +import { packwizCommand } from "./commands/packwiz/index.js" +import { modrinthCommand } from "./commands/modrinth/index.js" const program = new Command("horizr") .version( - (await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version, + (await fs.readJson(AbsolutePath.create(import.meta.url.slice(5)).parent().resolve("../package.json").toString())).version, "-v, --version" ) .option("--clear-cache", "Clear the HTTP cache before doing the operation.") .on("option:clear-cache", () => { - clearCache() + clearGotCache() output.println(kleur.green("Cache was cleared.\n")) }) - -program.command("init ") - .description("Initialize a new pack in the directory.") - .action(async path => { - const directoryPath = Path.create(path) - const horizrFilePath = directoryPath.resolve("horizr.json") - - if (await fs.pathExists(horizrFilePath.toString())) output.failAndExit(`${kleur.yellow("horizr.json")} already exists in the directory.`) - - await fs.mkdirp(directoryPath.toString()) - const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions") - - const answers: any = await enquirer.prompt([ - { - name: "name", - type: "input", - message: "Name", - validate: answer => answer.length === 0 ? "An answer is required." : true - }, - { - name: "authors", - type: "input", - message: "Authors (comma-separated)", - validate: answer => answer.length === 0 ? "An answer is required." : true - }, - { - name: "description", - type: "text", - message: "Description" - }, - { - name: "license", - type: "text", - message: "License (SPDX-ID)", - validate: answer => answer.length === 0 ? "An answer is required." : true - }, - { - name: "minecraftVersion", - type: "autocomplete", - message: "Minecraft version", - choices: minecraftVersions.map(version => ({ - name: version, - value: version - })), - // @ts-expect-error - limit: 10, - validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list." - } - ]) - - const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0] - - const file: HorizrFile = { - formatVersion: CURRENT_HORIZR_FILE_FORMAT_VERSION, - meta: { - name: answers.name, - version: "1.0.0", - description: answers.description === "" ? undefined : answers.description, - authors: (answers.authors as string).split(", ").map(a => a.trim()), - license: answers.license - }, - versions: { - minecraft: answers.minecraftVersion, - fabric: fabricVersion - } - } - - await fs.writeJson(horizrFilePath.toString(), file, { spaces: 2 }) - await fs.writeFile(directoryPath.resolve(".gitignore").toString(), "/generated/") - - const relativePath = Path.create(process.cwd()).relative(directoryPath).toString() - if (relativePath === "") output.println(kleur.green(`Successfully initialized pack.`)) - else output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(relativePath)}.`)) - }) - -program.command("info", { isDefault: true }) - .description("Print information about the pack.") - .action(async () => { - const pack = await usePack() - const disabledModsCount = pack.mods.filter(mod => !mod.modFile.enabled).length - const { description } = pack.horizrFile.meta - - output.println(dedent` - ${kleur.underline(pack.horizrFile.meta.name)} ${kleur.dim(`(${pack.horizrFile.meta.version})`)} - ${description === undefined ? "" : wrapAnsi(description, process.stdout.columns) + "\n"}\ - - Authors: ${kleur.yellow(pack.horizrFile.meta.authors.join(", "))} - License: ${kleur.yellow(pack.horizrFile.meta.license.toUpperCase())} - Mods: ${kleur.yellow(pack.mods.length.toString())}${disabledModsCount === 0 ? "" : ` (${disabledModsCount} disabled)`} - - Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)} - `) - }) - -program.command("remove ") - .description("Remove the mod from the pack.") - .action(async code => { - const pack = await usePack() - const mod = pack.findModByCodeOrFail(code) - - await removeModFile(pack.paths.root, mod.id) - - output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`) - }) - -program.command("update [code]") - .description("Check for updates of all mods or update a specific mod") - .option("-y, --yes", "Skip confirmations") - .option("-b, --allow-beta", "Allow beta versions") - .option("-a, --allow-alpha", "Allow alpha and beta versions") - .action(async (code, options) => { - const pack = await usePack() - const allowedReleaseChannels = releaseChannelOrder.slice(releaseChannelOrder.indexOf(options.allowAlpha ? "alpha" : options.allowBeta ? "beta" : "release")) - - if (code === undefined) { - const updates = await pack.checkForUpdates(allowedReleaseChannels) - - if (updates.length === 0) output.println(kleur.green("Everything up-to-date.")) - else { - output.println(dedent` - ${kleur.underline("Available updates")} - - ${updates.map(update => `- ${kleur.gray(update.mod.id)} ${update.mod.modFile.name}: ${kleur.red(update.activeVersion)} ${figures.arrowRight} ${kleur.green(update.availableVersion)}`).join("\n")} - `) - } - } else { - const mod = pack.findModByCodeOrFail(code) - const update = await output.withLoading(mod.checkForUpdate(allowedReleaseChannels), "Checking for an update") - - if (update === null) { - output.println(kleur.green("No update available.")) - } else { - if (update.changelog === null) { - output.println(`No changelog available for ${kleur.bold(update.availableVersion)}.`) - } else { - output.println(`${kleur.underline("Changelog")} for ${kleur.bold().yellow(update.availableVersion)}\n`) - output.printlnWrapping(update.changelog) - } - - output.println("") - - const confirmed = options.yes || (await enquirer.prompt({ - type: "confirm", - name: "confirmed", - message: "Apply the update?" - }) as any).confirmed - - if (confirmed) { - await output.withLoading(update.apply(), "Updating") - output.println(kleur.green(`Successfully updated ${kleur.yellow(update.mod.modFile.name)} to ${kleur.yellow(update.availableVersion)}.`)) - } - } - } - }) + .addCommand(modrinthCommand) + .addCommand(packwizCommand) + .addCommand(infoCommand) + .addCommand(initCommand) + .addCommand(updateCommand) loudRejection(stack => { output.failAndExit(stack) }) -await program - .addCommand(packwizCommand) - .addCommand(modrinthCommand) - .addHelpText("after", "\n" + dedent` - ${kleur.blue("code")} can be one of the following: - - The name of a file in the ${kleur.yellow("mods")} directory, optionally without the ${kleur.yellow(".json")} extension - - The ID of a Modrinth Project, prefixed with ${kleur.yellow("mr:")} - - The ID of a Modrinth Version, prefixed with ${kleur.yellow("mrv:")} - `) - .parseAsync(process.argv) +await program.parseAsync(process.argv) diff --git a/src/modrinth/api.ts b/src/modrinth/api.ts index 927b709..2701d82 100644 --- a/src/modrinth/api.ts +++ b/src/modrinth/api.ts @@ -1,16 +1,20 @@ import { HTTPError, Response } from "got" +import { output } from "../utils/output.js" import kleur from "kleur" -import { delay, got } from "../utils.js" -import { output } from "../output.js" -import { dependencyToRelatedVersionType } from "./utils.js" -import { ReleaseChannel } from "../shared.js" +import { got } from "../utils/http.js" +import { delay } from "../utils/promises.js" +import { dependencyToRelatedVersionType } from "./index.js" +import { ReleaseChannel } from "../pack.js" +import { orEmptyString } from "../utils/strings.js" + +const BASE_URL = "https://api.modrinth.com" async function getModrinthApiOptional(url: string): Promise { let response: Response while (true) { response = await got(url, { - prefixUrl: "https://api.modrinth.com", + prefixUrl: BASE_URL, throwHttpErrors: false, retry: { limit: 3, @@ -56,7 +60,7 @@ async function getModrinthApiOptional(url: string): Promise { async function getModrinthApi(url: string): Promise { const response = await getModrinthApiOptional(url) - if (response === null) return output.failAndExit("Request failed with status code 404.") + if (response === null) return output.failAndExit(`Request failed with status code 404: ${kleur.yellow(BASE_URL + "/" + url)}`) return response } @@ -188,8 +192,8 @@ export const modrinthApi = { updateDate: new Date(response.updated) } }, - async listVersions(idOrSlug: string, minecraftVersion: string): Promise { - const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`) + async listVersions(idOrSlug: string, minecraftVersion?: string): Promise { + const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]${orEmptyString(minecraftVersion, v => `&game_versions=["${v}"]`)}`) return response.map(transformApiModVersion) }, diff --git a/src/modrinth/index.ts b/src/modrinth/index.ts new file mode 100644 index 0000000..dfccbfa --- /dev/null +++ b/src/modrinth/index.ts @@ -0,0 +1,110 @@ +import { IterableElement } from "type-fest" +import { modrinthApi, ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js" +import { sortBy } from "lodash-es" +import { MetaFile, Pack, releaseChannelOrder } from "../pack.js" +import { MetaFileContentVersion } from "../files.js" +import { output } from "../utils/output.js" +import kleur from "kleur" + +export const dependencyToRelatedVersionType: Record["type"]> = { + required: "hard_dependency", + optional: "soft_dependency", + embedded: "embedded_dependency", + incompatible: "incompatible" +} + +export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse() + +export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) => + modrinthVersion.supportedMinecraftVersions.includes(pack.manifest.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric") + +export function getMetaFileContentVersionForModrinth(modrinthVersion: ModrinthVersion): MetaFileContentVersion { + const modrinthVersionFile = findCorrectModVersionFile(modrinthVersion.files) + + return { + name: modrinthVersion.versionString, + fileName: modrinthVersionFile.fileName, + hashes: { + sha1: modrinthVersionFile.hashes.sha1, + sha512: modrinthVersionFile.hashes.sha512 + }, + downloadUrl: modrinthVersionFile.url, + size: modrinthVersionFile.sizeInBytes + } +} + +export function findCorrectModVersionFile(files: ModrinthVersionFile[]) { + const primary = files.find(file => file.isPrimary) + + if (primary !== undefined) return primary + + // shortest file name + return files.sort((a, b) => a.fileName.length - b.fileName.length)[0] +} + +export const getSideOfModrinthMod = (modrinthMod: ModrinthMod) => + modrinthMod.serverSide !== "unsupported" && modrinthMod.clientSide !== "unsupported" + ? "universal" + : modrinthMod.clientSide !== "unsupported" ? "client" : "server" + +export async function resolveModrinthCode(code: string): Promise<{ modrinthMod: ModrinthMod; modrinthVersion: ModrinthVersion | null }> { + const resolveMod = async (slugOrId: string) => { + const modrinthMod = await modrinthApi.getMod(slugOrId) + if (modrinthMod === null) return output.failAndExit(`Unknown mod: ${kleur.yellow(slugOrId)}`) + return { + modrinthMod, + modrinthVersion: null + } + } + + const resolveVersionByName = async (modrinthMod: ModrinthMod, name: string) => { + const modrinthVersions = await modrinthApi.listVersions(modrinthMod.id) + + const modrinthVersion = modrinthVersions.find(v => v.versionString === name) + if (modrinthVersion === undefined) return output.failAndExit(`Unknown version: ${kleur.yellow(name)}`) + + return { + modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!, + modrinthVersion + } + } + + const resolveVersion = async (id: string) => { + const modrinthVersion = await modrinthApi.getVersion(id) + if (modrinthVersion === null) return output.failAndExit(`Unknown version: ${kleur.yellow(id)}`) + + return { + modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!, + modrinthVersion + } + } + + const parts = code.split("@") + if (parts.length === 2 && parts[0] === "") return resolveVersion(code.slice(1)) + if (parts.length <= 2 && !code.startsWith("https://")) { + const value = await resolveMod(parts[0]) + + if (parts.length === 2) return resolveVersionByName(value.modrinthMod, parts[1]) + else return value + } + + try { + const url = new URL(code) + const pathSegments = url.pathname.slice(1).split("/") + if (!(code.startsWith("https://modrinth.com/mod/") && (pathSegments.length === 2 || pathSegments.length === 4))) + output.failAndExit("Only Modrinth mod and version URLs are supported.") + + const value = await resolveMod(pathSegments[1]) + + if (pathSegments.length === 4) return resolveVersionByName(value.modrinthMod, pathSegments[3]) + else return value + } catch (e: unknown) { + // TypeError means code is not a URL + if (!(e instanceof TypeError)) throw e + } + + return output.failAndExit(`Invalid ${kleur.yellow("")}: ${kleur.yellow(code)}`) +} + +export const findMetaFileForModrinthMod = (metaFiles: MetaFile[], modrinthMod: ModrinthMod) => + metaFiles.find(metaFile => metaFile.content.source?.type === "modrinth" && metaFile.content.source.modId === modrinthMod.id) ?? null diff --git a/src/modrinth/updating.ts b/src/modrinth/updating.ts new file mode 100644 index 0000000..c0222c3 --- /dev/null +++ b/src/modrinth/updating.ts @@ -0,0 +1,56 @@ +import { MetaFile, ReleaseChannel, Update } from "../pack.js" +import { MetaFileModrinthSource } from "../files.js" +import { modrinthApi, ModrinthVersion } from "./api.js" +import semver from "semver" +import { getMetaFileContentVersionForModrinth } from "./index.js" +import { sortBy } from "lodash-es" + +async function fetchNewerModrinthVersions( + activeVersion: string, + source: MetaFileModrinthSource, + allowedReleaseChannels: ReleaseChannel[], + minecraftVersion: string +): Promise { + const activeSemver = semver.parse(activeVersion) + const availableVersions = await modrinthApi.listVersions(source.modId, minecraftVersion) + const allowedVersions = availableVersions.filter(version => allowedReleaseChannels.includes(version.releaseChannel)) + + if (activeSemver === null) { + const activePublicationDate = allowedVersions.find(v => v.id === source.versionId)?.publicationDate + if (activePublicationDate === undefined) return allowedVersions + + return allowedVersions.filter(v => v.publicationDate.toISOString() > activePublicationDate.toISOString()) + } else { + return allowedVersions.filter(version => { + const thisSemver = semver.parse(version.versionString) + + // If mods switch to a non-SemVer version scheme, all new versions are considered older. + // This may be a problem. + if (thisSemver === null) return false + + return thisSemver.compare(activeSemver) === 1 + }) + } +} + +export async function fetchModrinthModUpdates( + metaFile: MetaFile, + source: MetaFileModrinthSource, + allowedReleaseChannels: ReleaseChannel[], + minecraftVersion: string +): Promise { + const sorted = sortBy(await fetchNewerModrinthVersions(metaFile.content.version.name, source, allowedReleaseChannels, minecraftVersion), v => v.publicationDate.toISOString()) + .reverse() + + return sorted.map(modrinthVersion => ({ + of: metaFile, + versionString: modrinthVersion.versionString, + changelog: modrinthVersion.changelog, + async apply() { + metaFile.content.version = getMetaFileContentVersionForModrinth(modrinthVersion) + source.versionId = modrinthVersion.id + + await metaFile.saveContent() + } + })) +} diff --git a/src/modrinth/utils.ts b/src/modrinth/utils.ts deleted file mode 100644 index 67e201b..0000000 --- a/src/modrinth/utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { IterableElement } from "type-fest" -import { sortBy } from "lodash-es" -import { Mod, Pack, usePack } from "../pack.js" -import { ModFile, ModFileData, ModFileModrinthSource } from "../files.js" -import { pathExists } from "fs-extra" -import { nanoid } from "nanoid/non-secure" -import { output } from "../output.js" -import kleur from "kleur" -import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js" -import { releaseChannelOrder, Side } from "../shared.js" - -export const dependencyToRelatedVersionType: Record["type"]> = { - required: "hard_dependency", - optional: "soft_dependency", - embedded: "embedded_dependency", - incompatible: "incompatible" -} - -export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse() - -export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<(Mod & { modFile: ModFile & { source: ModFileModrinthSource } }) | null> { - const pack = await usePack() - - return ( - pack.mods.find( - mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === modrinthMod.id - ) as (Mod & { modFile: Mod & { source: ModFileModrinthSource } }) | undefined - ) ?? null -} - -export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) => - modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric") - -export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData { - const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files) - - return { - version: modrinthModVersion.versionString, - hashes: { - sha1: modrinthVersionFile.hashes.sha1, - sha512: modrinthVersionFile.hashes.sha512 - }, - downloadUrl: modrinthVersionFile.url, - name: modrinthVersionFile.fileName, - size: modrinthVersionFile.sizeInBytes, - } -} - -export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, side?: Side) { - const pack = await usePack() - let id = modrinthMod.slug - - if (await pathExists(pack.paths.mods.resolve(`${id}.json`).toString())) { - const oldId = id - id = `${id}-${nanoid(5)}` - - output.warn( - `There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` + - `The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}` - ) - } - - if (side === undefined) { - const isClientSupported = modrinthMod.clientSide !== "unsupported" - const isServerSupported = modrinthMod.serverSide !== "unsupported" - - side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server" - } - - await pack.addMod(id, { - name: modrinthMod.title, - enabled: true, - ignoreUpdates: false, - side, - file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion), - source: { - type: "modrinth", - modId: modrinthMod.id, - versionId: modrinthVersion.id - } - }) -} - -export function findCorrectModVersionFile(files: ModrinthVersionFile[]) { - const primary = files.find(file => file.isPrimary) - - if (primary !== undefined) return primary - - // shortest file name - return files.sort((a, b) => a.fileName.length - b.fileName.length)[0] -} diff --git a/src/pack.ts b/src/pack.ts index 400ace0..cbb5ba8 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -1,173 +1,162 @@ -import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js" -import { output } from "./output.js" -import pLimit from "p-limit" +import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js" +import { + findPackDirectoryPath, + PackManifest, + horizrFileSchema, + MetaFileContent, + PACK_MANIFEST_FILE_NAME, + metaFileContentSchema, + writeJsonFile, + listSourceFiles, + readJsonFile, META_FILE_EXTENSION +} from "./files.js" +import { z, ZodRawShape } from "zod" +import { fetchModrinthModUpdates } from "./modrinth/updating.js" +import { createCpuCoreLimiter } from "./utils/promises.js" +import { output } from "./utils/output.js" +import pathModule from "path" import kleur from "kleur" -import { modrinthApi } from "./modrinth/api.js" -import semver from "semver" -import { Path } from "./path.js" -import { ReleaseChannel, Side, sides } from "./shared.js" -import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js" +import { orEmptyString } from "./utils/strings.js" +import fs from "fs-extra" + +export type ReleaseChannel = "alpha" | "beta" | "release" +export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"] + +export type Side = "client" | "server" | "universal" +export const sides: [Side, ...Side[]] = ["client", "server", "universal"] + +export interface Pack { + manifest: PackManifest + + paths: { + root: AbsolutePath + source: AbsolutePath + exports: AbsolutePath + } + + metaFiles: MetaFile[] + staticSourceFiles: StaticSourceFile[] + getMetaFile(path: RelativePath): MetaFile | null + getMetaFileFromInput(input: string): MetaFile + getEffectiveMetaFile(path: RelativePath, side: Side): MetaFile | null + registerCreatedSourceFile(path: RelativePath): Promise + + readSourceJsonFile>(path: RelativePath, schema: S): Promise | null> +} + +export interface SourceFile { + isStatic: boolean + isMod: boolean + side: Side + relativePath: RelativePath + absolutePath: AbsolutePath + effectivePath: RelativePath +} + +export interface MetaFile extends SourceFile { + isStatic: false + content: MetaFileContent + fetchUpdates: null | ((allowedReleaseChannels: ReleaseChannel[]) => Promise) + saveContent(): Promise + + getDisplayString(): string +} + +export interface StaticSourceFile extends SourceFile { + isStatic: true +} export interface Update { - mod: Mod - activeVersion: string - availableVersion: string + of: MetaFile + versionString: string changelog: string | null apply(): Promise } -export interface Pack { - paths: { - root: Path, - mods: Path, - generated: Path, - overrides: Record - }, - horizrFile: HorizrFile - mods: Mod[] - - addMod(id: string, file: ModFile): Promise - findModByCode(code: string): Mod | null - findModByCodeOrFail(code: string): Mod - validateOverridesDirectories(): Promise - - checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise -} - -export interface Mod { - id: string - - modFile: ModFile - saveModFile(): Promise - - checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise -} - let pack: Pack - export async function usePack(): Promise { if (pack === undefined) { const rootDirectoryPath = await findPackDirectoryPath() - const overridesDirectoryPath = rootDirectoryPath.resolve("overrides") + const sourceDirectoryPath = rootDirectoryPath.resolve("src") + const readSourceJsonFile: Pack["readSourceJsonFile"] = async (path, schema) => readJsonFile(sourceDirectoryPath, path, schema) + + const manifest = (await readJsonFile(rootDirectoryPath, RelativePath.create(PACK_MANIFEST_FILE_NAME), horizrFileSchema))! + + const metaFiles: MetaFile[] = [] + const staticSourceFiles: StaticSourceFile[] = [] + + const registerSourceFile: Pack["registerCreatedSourceFile"] = async relativePath => { + const absolutePath = sourceDirectoryPath.resolve(relativePath) + if (!await fs.pathExists(absolutePath.toString())) throw new Error("File does not exist: " + absolutePath) + + const pathSegments = relativePath.toString().split("/") + + const sourceFile: SourceFile = { + isStatic: false, + isMod: pathSegments[1] === "mods", + side: pathSegments[0] as Side, + relativePath: relativePath, + absolutePath: sourceDirectoryPath.resolve(relativePath), + effectivePath: RelativePath._createDirect(pathSegments.slice(1).join("/")), + } + + if (relativePath.toString().endsWith("." + META_FILE_EXTENSION)) { + const content = (await readSourceJsonFile(relativePath, metaFileContentSchema))! + const { source } = content + + const metaFile: MetaFile = { + ...sourceFile, + isStatic: false, + content, + fetchUpdates: source?.type === "modrinth" + ? allowedReleaseChannels => fetchModrinthModUpdates(metaFile, source, allowedReleaseChannels, manifest.versions.minecraft) + : null, + async saveContent() { + await writeJsonFile(sourceDirectoryPath.resolve(relativePath), metaFileContentSchema, this.content) + }, + getDisplayString: () => `${kleur.yellow(metaFile.relativePath.toString())}${orEmptyString(metaFile.content.displayName, v => " " + kleur.blue(v))}` + } + + metaFiles.push(metaFile) + } else { + staticSourceFiles.push({ + ...sourceFile, + isStatic: true + }) + } + } + + const sourceFilePaths = await listSourceFiles(sourceDirectoryPath) + const limit = createCpuCoreLimiter() + await Promise.all(sourceFilePaths.map(path => limit(() => registerSourceFile(path)))) pack = { paths: { root: rootDirectoryPath, - generated: rootDirectoryPath.resolve("generated"), - mods: rootDirectoryPath.resolve("mods"), - overrides: { - client: overridesDirectoryPath.resolve("client"), - server: overridesDirectoryPath.resolve("server"), - "client-server": overridesDirectoryPath.resolve("client-server") - } + source: sourceDirectoryPath, + exports: rootDirectoryPath.resolve("exports") }, - horizrFile: await readHorizrFile(rootDirectoryPath), - mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => { - const mod: Mod = { - id, - modFile: (await readModFile(rootDirectoryPath, id))!, - async saveModFile() { - await writeModFile(rootDirectoryPath, id, this.modFile) - }, - async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise { - if (mod.modFile.ignoreUpdates) return null - - if (mod.modFile.source.type === "modrinth") { - const activeVersionString = mod.modFile.file.version - const activeSemver = semver.parse(activeVersionString) - if (activeSemver === null) output.warn( - `${kleur.yellow(mod.modFile.name)} has no valid semantic version: ${kleur.yellow(mod.modFile.file.version)}. ` + - `The publication date will instead be used.` - ) - - const versions = await modrinthApi.listVersions(mod.modFile.source.modId, pack.horizrFile.versions.minecraft) - const allowedVersions = versions.filter(version => allowedReleaseChannels.includes(version.releaseChannel)) - - const newerVersions = activeSemver === null ? allowedVersions : allowedVersions.filter(version => { - const thisSemver = semver.parse(version.versionString) - if (thisSemver === null) return false - - return thisSemver.compare(activeSemver) === 1 - }) - - if (newerVersions.length === 0) return null - - const sortedNewerVersions = sortModrinthVersionsByPreference(newerVersions) - const newestVersion = sortedNewerVersions[0] - - if (activeSemver === null ? activeVersionString === newestVersion.versionString : semver.eq(activeSemver, newestVersion.versionString)) return null - - return { - mod, - activeVersion: activeVersionString, - availableVersion: newestVersion.versionString, - changelog: newestVersion.changelog, - async apply() { - const modrinthMod = (await modrinthApi.getMod(newestVersion.projectId))! - - mod.modFile.file = getModFileDataForModrinthVersion(modrinthMod, newestVersion) - ;(mod.modFile.source as ModFileModrinthSource).versionId = newestVersion.id - - await mod.saveModFile() - } - } - } else { - output.warn(`${kleur.yellow(mod.modFile.name)} has no source information attached.`) - } - - return null - } - } - - return mod - })), - async addMod(id: string, file: ModFile) { - await writeModFile(rootDirectoryPath, id, file) + manifest, + metaFiles, + staticSourceFiles, + readSourceJsonFile, + registerCreatedSourceFile: registerSourceFile, + getMetaFile(relativePath: RelativePath) { + return metaFiles.find(metaFile => metaFile.relativePath.is(relativePath)) ?? null }, - findModByCode(code: string): Mod | null { - if (code.startsWith("mrv:")) { - return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.versionId === code.slice(4)) ?? null - } else if (code.startsWith("mr:")) { - return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === code.slice(3)) ?? null - } else if (code.endsWith(".json")) { - return this.mods.find(mod => mod.id === code.slice(0, -5)) ?? null - } else { - return this.mods.find(mod => mod.id === code) ?? null - } + getEffectiveMetaFile(effectivePath: RelativePath, side: Side) { + return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? null }, - findModByCodeOrFail(code: string): Mod { - const mod = this.findModByCode(code) - if (mod === null) return output.failAndExit("The mod could not be found.") - return mod - }, - async validateOverridesDirectories() { - const dirents = await getOverrideDirents(overridesDirectoryPath) + getMetaFileFromInput(input: string): MetaFile { + const path = envPaths.cwd.resolveAny(input) + if (!path.isDescendantOf(sourceDirectoryPath)) output.failAndExit(`${kleur.yellow(pathModule.normalize(input))} is outside the source directory.`) - const notDirectories = dirents.filter(dirent => !dirent.isDirectory()) - if (notDirectories.length !== 0) - output.failAndExit( - `The ${kleur.yellow("overrides")} directory contains files that are not directories:\n${notDirectories.slice(0, 5).map(e => `- ${e.name}`).join("\n")}` + - (notDirectories.length > 5 ? `\n${kleur.gray(`and ${notDirectories.length - 5} more`)}` : "") + - `\n\nAll files must reside in one of these sub-directories: ${sides.map(kleur.yellow).join(", ")}` - ) + const relativePath = sourceDirectoryPath.relativeTo(path.toString().endsWith("." + META_FILE_EXTENSION) ? path : (path.toString() + "." + META_FILE_EXTENSION)) - if (dirents.some(dirent => !(sides as string[]).includes(dirent.name))) - output.failAndExit(`The ${kleur.yellow("overrides")} directory may only contain the following sub-directories:\n${sides.map(side => `- ${side}`).join("\n")}`) - }, - async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise { - const limit = pLimit(5) + const metaFile = this.getMetaFile(relativePath) + if (metaFile === null) return output.failAndExit(`${kleur.yellow(relativePath.toString())} does not exist.`) - const loader = output.startLoading(`Checking for updates (0/${this.mods.length})`) - let finishedCount = 0 - const updates: Array = await Promise.all(this.mods.map(mod => limit(async () => { - const update = await mod.checkForUpdate(allowedReleaseChannels) - finishedCount++ - loader.setText(`Checking for updates (${finishedCount}/${this.mods.length})`) - return update - }))) - - loader.stop() - return updates.filter(info => info !== null) as Update[] + return metaFile } } } diff --git a/src/packwiz/exporting.ts b/src/packwiz/exporting.ts new file mode 100644 index 0000000..8f771b4 --- /dev/null +++ b/src/packwiz/exporting.ts @@ -0,0 +1,104 @@ +import { AbsolutePath, RelativePath } from "../utils/path.js" +import dedent from "dedent" +import fs from "fs-extra" +import { MetaFile, StaticSourceFile, usePack } from "../pack.js" +import pathModule from "path" +import { computeSha512HexHash, computeSha512HexHashForFile } from "../utils/misc.js" +import { orEmptyString } from "../utils/strings.js" +import { META_FILE_EXTENSION } from "../files.js" + +export const PACKWIZ_EXPORT_DIRECTORY_NAME = "packwiz" + +export interface IndexedFile { + path: RelativePath + sha512HashHex: string + isMeta: boolean +} + +export async function writeAndIndexStaticSourceFile( + indexedFiles: IndexedFile[], + outputDirectoryPath: AbsolutePath, + staticSourceFile: StaticSourceFile +) { + const outputPath = outputDirectoryPath.resolve(staticSourceFile.effectivePath) + + await fs.mkdirp(outputPath.parent().toString()) + await fs.copy(staticSourceFile.absolutePath.toString(), outputPath.toString()) + + indexedFiles.push({ + path: staticSourceFile.effectivePath, + isMeta: false, + sha512HashHex: await computeSha512HexHashForFile(outputPath) + }) +} + +export async function writeAndIndexMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath, metaFile: MetaFile) { + const updateSection = metaFile.content.source?.type === "modrinth" + ? dedent` + \n\n[update] + [update.modrinth] + mod-id = ${JSON.stringify(metaFile.content.source.modId)} + version = ${JSON.stringify(metaFile.content.source.versionId)} + ` + : "" + + const content = dedent` + name = ${JSON.stringify(metaFile.content.displayName ?? pathModule.basename(metaFile.relativePath.toString()))} + filename = ${JSON.stringify(metaFile.content.version.fileName)} + side = "${metaFile.side.replace("universal", "both")}" + + [download] + hash-format = "sha512" + hash = ${JSON.stringify(metaFile.content.version.hashes.sha512)} + url = ${JSON.stringify(metaFile.content.version.downloadUrl)}${updateSection} + ` + + const effectiveOutputPath = metaFile.effectivePath + .parent() + .joinedWith(metaFile.effectivePath.getBasename().slice(0, -1 * META_FILE_EXTENSION.length) + "toml") + + const outputPath = outputDirectoryPath.resolve(effectiveOutputPath) + + await fs.mkdirp(outputPath.parent().toString()) + await fs.writeFile(outputPath.toString(), content) + + indexedFiles.push({ + path: metaFile.effectivePath, + isMeta: true, + sha512HashHex: await computeSha512HexHash(content) + }) +} + +export async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath) { + const pack = await usePack() + + const index = dedent` + hash-format = "sha512" + + ${indexedFiles.map(file => dedent` + [[files]] + file = ${JSON.stringify(file.path.toString())} + hash = "${file.sha512HashHex}" + metafile = ${file.isMeta} + `).join("\n\n")} + ` + + await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index) + const indexHash = await computeSha512HexHash(index) + + await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent` + name = ${JSON.stringify(pack.manifest.meta.name)} + author = ${JSON.stringify(pack.manifest.meta.authors.join(", "))}\ + ${orEmptyString(pack.manifest.meta.description, d => `\ndescription = ${JSON.stringify(d)}`)} + pack-format = "packwiz:1.1.0" + + [versions] + minecraft = ${JSON.stringify(pack.manifest.versions.minecraft)} + fabric = ${JSON.stringify(pack.manifest.versions.fabric)} + + [index] + file = "index.toml" + hash-format = "sha512" + hash = "${indexHash}" + `) +} diff --git a/src/path.ts b/src/path.ts deleted file mode 100644 index 2417206..0000000 --- a/src/path.ts +++ /dev/null @@ -1,67 +0,0 @@ -import pathModule from "path" -import envPaths from "env-paths" - -export class Path { - constructor(private readonly value: string) { - } - - /** - * Returns an absolute path by resolving the last segment against the other segments, this path and the current working directory. - */ - resolve(...segments: (string | Path)[]) { - return new Path(pathModule.resolve(this.value, ...segments.map(s => s.toString()))) - } - - /** - * Returns a new path with this path and the segments joined together. - */ - join(...segments: (string | Path)[]) { - return new Path(pathModule.join(this.value, ...segments.map(s => s.toString()))) - } - - /** - * Returns the relative path from this path to the other path. - */ - relative(other: Path | string) { - return new Path(pathModule.relative(this.value, typeof other === "string" ? other : other.toString())) - } - - getParent() { - return new Path(pathModule.dirname(this.value)) - } - - isAbsolute() { - return pathModule.isAbsolute(this.value) - } - - // Not tested - // isDescendantOf(other: Path) { - // if (!(this.isAbsolute() && other.isAbsolute())) throw new Error("Both paths must be absolute") - // return pathModule.relative(this.value, other.value).split("/").includes("..") - // } - - toString() { - return this.value - } - - static create(...segments: string[]) { - if (segments.length === 0) throw new Error("At least one segment is required") - - return new Path(pathModule.join(...segments)) - } - - static createAbsolute(...segments: string[]) { - if (segments.length === 0) throw new Error("At least one segment is required") - - return new Path(pathModule.resolve(...segments)) - } -} - -const rawPaths = envPaths("horizr", { suffix: "" }) -export const paths = { - cache: new Path(rawPaths.cache), - config: new Path(rawPaths.config), - data: new Path(rawPaths.data), - log: new Path(rawPaths.log), - temp: new Path(rawPaths.temp) -} diff --git a/src/shared.ts b/src/shared.ts deleted file mode 100644 index fb270da..0000000 --- a/src/shared.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ReleaseChannel = "alpha" | "beta" | "release" -export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"] - -export type Side = "client" | "server" | "client-server" -export const sides: [Side, ...Side[]] = ["client", "server", "client-server"] diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 6193ba6..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { InvalidArgumentError } from "commander" -import hash, { HashaInput } from "hasha" -import { Path, paths } from "./path.js" -import { ZipFile } from "yazl" -import { walk } from "@root/walk" -import fs from "fs-extra" -import { pEvent } from "p-event" -import serveHandler from "serve-handler" -import * as http from "http" -import addressWithCallback from "address" -import { promisify } from "util" -import { KeyvFile } from "keyv-file" -import originalGot from "got" -import { dirname } from "path" -import { without } from "lodash-es" - -const keyvCache = new KeyvFile({ - filename: paths.cache.resolve("http.json").toString(), - writeDelay: 50, - expiredCheckDelay: 24 * 3600 * 1000, - encode: JSON.stringify, - decode: JSON.parse -}) - -export const clearCache = () => keyvCache.clear() - -export const got = originalGot.extend({ - cache: keyvCache, - responseType: "json", - headers: { - "User-Agent": "moritzruth/horizr/1.0.0 (not yet public)" - } -}) - -const address = promisify(addressWithCallback) -export const getLANAddress = () => address().then(r => r.ip) - -export function createSingleConcurrencyWithQueue(fn: () => Promise) { - let state: "inactive" | "running_fresh" | "running_old" = "inactive" - - return async () => { - if (state === "inactive") { - const loop = () => { - state = "running_fresh" - - fn().then(() => { - if (state === "running_old") loop() - }) - } - - loop() - } else { - state = "running_old" - } - } -} - -export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) { - const server = http.createServer((request, response) => { - return serveHandler(request, response, { - directoryListing: false, - public: path.toString(), - cleanUrls: false, - headers: [ - { - source: "**/*.toml", - headers: [{ - key: "Content-Type", - value: "application/toml" - }] - } - ] - }) - }) - - server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => { - onListen() - }) -} - -export async function zipDirectory(directoryPath: Path, outputFilePath: Path) { - const zipFile = new ZipFile() - zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString())) - - let emptyDirectories: string[] = [] - await walk(directoryPath.toString(), async (error, path, dirent) => { - if (error) return - if (directoryPath.toString() === path) return true - if (dirent.name.startsWith(".")) return false - - if (dirent.isDirectory()) { - emptyDirectories.push(path) - } else if (dirent.isFile()) { - zipFile.addFile(path, directoryPath.relative(path).toString(), { compress: true }) - } else return - - emptyDirectories = without(emptyDirectories, dirname(path)) - }) - - emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relative(p).toString())) - - zipFile.end() - await pEvent(zipFile.outputStream, "close") -} - -export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -export const getSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" }) - -export function truncateWithEllipsis(text: string, maxLength: number) { - if (text.length <= maxLength) return text - - return text.slice(0, maxLength - 1).trimEnd() + "…" -} - -export function optionParseInteger(value: string): number { - const parsed = parseInt(value, 10) - if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.") - - return parsed -} - -export function optionParsePositiveInteger(value: string): number { - const parsed = parseInt(value, 10) - if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.") - - return parsed -} diff --git a/src/utils/collections.ts b/src/utils/collections.ts new file mode 100644 index 0000000..8bfc021 --- /dev/null +++ b/src/utils/collections.ts @@ -0,0 +1,16 @@ +export function mapNotNull(array: T[], fn: (item: T, index: number) => R | null): R[] { + const result: R[] = [] + + let index = 0 + for (const item of array) { + const mapped = fn(item, index) + if (mapped !== null) result.push(mapped) + index++ + } + + return result +} + +export function filterNulls(array: T[]): Exclude[] { + return array.filter(i => i !== null) as Exclude[] +} diff --git a/src/utils/http.ts b/src/utils/http.ts new file mode 100644 index 0000000..675145f --- /dev/null +++ b/src/utils/http.ts @@ -0,0 +1,69 @@ +import { KeyvFile } from "keyv-file" +import { Path, envPaths } from "./path.js" +import originalGot from "got" +import http from "http" +import serveHandler from "serve-handler" +import { getLANAddress } from "./misc.js" +import { output } from "./output.js" +import dedent from "dedent" +import kleur from "kleur" + +const keyvCache = new KeyvFile({ + filename: envPaths.cache.resolve("http.json").toString(), + writeDelay: 50, + expiredCheckDelay: 24 * 3600 * 1000, + encode: JSON.stringify, + decode: JSON.parse +}) + +export const clearGotCache = () => keyvCache.clear() + +export const got = originalGot.extend({ + cache: keyvCache, + responseType: "json", + headers: { + "User-Agent": "moritzruth/horizr/1.0.0 (not yet public)" + } +}) + +export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) { + const server = http.createServer((request, response) => { + return serveHandler(request, response, { + directoryListing: false, + public: path.toString(), + cleanUrls: false, + headers: [ + { + source: "**/*.toml", + headers: [{ + key: "Content-Type", + value: "application/toml" + }] + } + ] + }) + }) + + server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => { + onListen() + }) +} + +export async function httpServeDirectoryWithMessage(path: Path, port: number, expose: boolean) { + const lanAddress = await getLANAddress() + const localAddress = `http://localhost:${port}` + + await new Promise(resolve => { + httpServeDirectory(path, port, expose, () => { + if (expose) { + output.println(dedent` + ${kleur.green("Serving at")} + Local: ${kleur.yellow(localAddress)} + Network: ${kleur.yellow(`http://${lanAddress}:${port}`)} + `) + } else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`) + + resolve() + }) + }) +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts new file mode 100644 index 0000000..b7db0e6 --- /dev/null +++ b/src/utils/misc.ts @@ -0,0 +1,10 @@ +import { promisify } from "util" +import addressWithCallback from "address" +import hash, { HashaInput } from "hasha" +import { AbsolutePath } from "./path.js" + +const address = promisify(addressWithCallback) +export const getLANAddress = () => address().then(r => r.ip) + +export const computeSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" }) +export const computeSha512HexHashForFile = (path: AbsolutePath) => hash.fromFile(path.toString(), { algorithm: "sha512", encoding: "hex" }) diff --git a/src/utils/options.ts b/src/utils/options.ts new file mode 100644 index 0000000..93b03a9 --- /dev/null +++ b/src/utils/options.ts @@ -0,0 +1,29 @@ +import { InvalidArgumentError } from "commander" +import { Side, sides } from "../pack.js" + +export function integerOption(value: string): number { + const parsed = parseInt(value, 10) + if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.") + + return parsed +} + +export function positiveIntegerOption(value: string): number { + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.") + + return parsed +} + +export function gtzIntegerOption(value: string): number { + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed <= 0) throw new InvalidArgumentError("Must be an integer > 0.") + + return parsed +} + +export function sideOption(value: string): Side { + if (!(sides as string[]).includes(value)) throw new InvalidArgumentError(`Must be one of ${sides.join(", ")}`) + + return value as Side +} diff --git a/src/output.ts b/src/utils/output.ts similarity index 100% rename from src/output.ts rename to src/utils/output.ts diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 0000000..7663e12 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,131 @@ +import pathModule from "path" +import getEnvPaths from "env-paths" + +interface AbstractPath { + isDescendantOf(other: Path): boolean + is(other: Path | string): boolean + + getBasename(): string + + toAbsolute(): AbsolutePath + toString(): string +} + +export type Path = AbsolutePath | RelativePath + +export class RelativePath implements AbstractPath { + private constructor(private readonly pathString: string) { + } + + isDescendantOf(other: Path) { + return this.pathString !== "" && !this.pathString.split("/").includes("..") + } + + resolveInCwd(...segments: (string | RelativePath)[]): AbsolutePath { + return AbsolutePath._createDirect(pathModule.resolve(this.pathString, ...segments.map(s => s.toString()))) + } + + joinedWith(...segments: (string | Path)[]): RelativePath { + return RelativePath._createDirect(pathModule.join(this.pathString, ...segments.map(s => s.toString()))) + } + + parent(): RelativePath { + return RelativePath._createDirect(pathModule.dirname(this.pathString)) + } + + is(other: Path | string): boolean { + return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString()) + } + + getBasename(): string { + return pathModule.basename(this.pathString) + } + + toAbsolute(): AbsolutePath { + return envPaths.cwd.resolve(this) + } + + toString(): string { + return this.pathString + } + + static create(pathString: string) { + if (pathModule.isAbsolute(pathString)) throw new Error("pathString is not relative") + return new RelativePath(pathModule.normalize(pathString)) + } + + static _createDirect(pathString: string) { + return new RelativePath(pathString) + } +} + +export class AbsolutePath implements AbstractPath { + private constructor(private readonly pathString: string) { + } + + isDescendantOf(other: Path) { + if (other instanceof AbsolutePath) { + return other.relativeTo(this).isDescendantOf(this) + } else return other.isDescendantOf(this) + } + + resolve(...segments: (string | RelativePath)[]): AbsolutePath { + return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString()))) + } + + resolveAny(...segments: (string | Path)[]): AbsolutePath { + return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString()))) + } + + joinedWith(...segments: (string | RelativePath)[]): AbsolutePath { + return new AbsolutePath(pathModule.join(this.pathString, ...segments.map(s => s.toString()))) + } + + parent(): AbsolutePath { + return new AbsolutePath(pathModule.dirname(this.pathString)) + } + + relativeTo(other: Path | string): RelativePath { + if (other instanceof RelativePath) return other + else return RelativePath._createDirect(pathModule.relative(this.pathString, typeof other === "string" ? other : other.toString())) + } + + is(other: Path | string): boolean { + return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString()) + } + + getBasename(): string { + return pathModule.basename(this.pathString) + } + + /** + * @deprecated Unnecessary. + */ + toAbsolute(): AbsolutePath { + return this + } + + toString(): string { + return this.pathString + } + + static create(pathString: string) { + if (!pathModule.isAbsolute(pathString)) throw new Error("pathString is not absolute") + return new AbsolutePath(pathModule.normalize(pathString)) + } + + static _createDirect(pathString: string) { + return new AbsolutePath(pathString) + } +} + +const rawPaths = getEnvPaths("horizr", { suffix: "" }) + +export const envPaths = { + cache: AbsolutePath.create(rawPaths.cache), + config: AbsolutePath.create(rawPaths.config), + data: AbsolutePath.create(rawPaths.data), + log: AbsolutePath.create(rawPaths.log), + temp: AbsolutePath.create(rawPaths.temp), + cwd: AbsolutePath.create(process.cwd()) +} diff --git a/src/utils/promises.ts b/src/utils/promises.ts new file mode 100644 index 0000000..a10e8d2 --- /dev/null +++ b/src/utils/promises.ts @@ -0,0 +1,26 @@ +import pLimit from "p-limit" +import os from "os" + +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +export const createCpuCoreLimiter = () => pLimit(os.cpus().length) + +export function createSingleConcurrencyWithQueue(fn: () => Promise) { + let state: "inactive" | "running_fresh" | "running_old" = "inactive" + + return async () => { + if (state === "inactive") { + const loop = () => { + state = "running_fresh" + + fn().then(() => { + if (state === "running_old") loop() + }) + } + + loop() + } else { + state = "running_old" + } + } +} diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..bc6bd88 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,9 @@ +export const orEmptyString = + (value: T, fn: (v: Exclude) => string): string => + value === undefined || value === null ? "" : fn(value as Exclude) + +export function truncateWithEllipsis(text: string, maxLength: number) { + if (text.length <= maxLength) return text + + return text.slice(0, maxLength - 1).trimEnd() + "…" +} diff --git a/src/utils/zip.ts b/src/utils/zip.ts new file mode 100644 index 0000000..786eb37 --- /dev/null +++ b/src/utils/zip.ts @@ -0,0 +1,32 @@ +import { AbsolutePath } from "./path.js" +import { ZipFile } from "yazl" +import fs from "fs-extra" +import { walk } from "@root/walk" +import { without } from "lodash-es" +import { pEvent } from "p-event" +import { dirname } from "path" + +export async function zipDirectory(directoryPath: AbsolutePath, outputFilePath: AbsolutePath) { + const zipFile = new ZipFile() + zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString())) + + let emptyDirectories: string[] = [] + await walk(directoryPath.toString(), async (error, path, dirent) => { + if (error) return + if (directoryPath.toString() === path) return true + if (dirent.name.startsWith(".")) return false + + if (dirent.isDirectory()) { + emptyDirectories.push(path) + } else if (dirent.isFile()) { + zipFile.addFile(path, directoryPath.relativeTo(path).toString(), { compress: true }) + } else return + + emptyDirectories = without(emptyDirectories, dirname(path)) + }) + + emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relativeTo(p).toString())) + + zipFile.end() + await pEvent(zipFile.outputStream, "close") +} diff --git a/src/commands/modrinth.ts b/src_old/commands/modrinth/index.ts similarity index 98% rename from src/commands/modrinth.ts rename to src_old/commands/modrinth/index.ts index b503454..ba244be 100644 --- a/src/commands/modrinth.ts +++ b/src_old/commands/modrinth/index.ts @@ -1,8 +1,8 @@ import { Command } from "commander" import { take } from "lodash-es" -import { usePack } from "../pack.js" +import { usePack } from "../../pack.js" import kleur from "kleur" -import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../utils.js" +import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../../utils.js" import { default as wrapAnsi } from "wrap-ansi" import figures from "figures" import { @@ -10,13 +10,13 @@ import { ModrinthMod, ModrinthVersion, ModrinthVersionRelation, -} from "../modrinth/api.js" +} from "../../modrinth/api.js" import dedent from "dedent" import ago from "s-ago" import semver from "semver" -import { output } from "../output.js" +import { output } from "../../../src/utils/output.js" import fs from "fs-extra" -import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js" +import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../../modrinth/utils.js" import { walk } from "@root/walk" const modrinthCommand = new Command("modrinth") diff --git a/src_old/modrinth/utils.ts b/src_old/modrinth/utils.ts new file mode 100644 index 0000000..21f6da2 --- /dev/null +++ b/src_old/modrinth/utils.ts @@ -0,0 +1,47 @@ +import { IterableElement } from "type-fest" +import { sortBy } from "lodash-es" +import { Mod, Pack, usePack } from "../pack.js" +import { ModFile, ModFileData, MetaFileModrinthSource } from "../files.js" +import { pathExists } from "fs-extra" +import { nanoid } from "nanoid/non-secure" +import { output } from "../../src/utils/output.js" +import kleur from "kleur" +import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js" +import { releaseChannelOrder, Side } from "../shared.js" + +export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, side?: Side) { + const pack = await usePack() + let id = modrinthMod.slug + + if (await pathExists(pack.paths.mods.resolve(`${id}.json`).toString())) { + const oldId = id + id = `${id}-${nanoid(5)}` + + output.warn( + `There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` + + `The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}` + ) + } + + if (side === undefined) { + const isClientSupported = modrinthMod.clientSide !== "unsupported" + const isServerSupported = modrinthMod.serverSide !== "unsupported" + + side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server" + } + + await pack.addMod(id, { + name: modrinthMod.title, + enabled: true, + ignoreUpdates: false, + side, + file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion), + source: { + type: "modrinth", + modId: modrinthMod.id, + versionId: modrinthVersion.id + } + }) +} + + diff --git a/test-pack/overrides/client-server/config/charm.ini b/test-pack/exports/packwiz/config/charm.ini similarity index 100% rename from test-pack/overrides/client-server/config/charm.ini rename to test-pack/exports/packwiz/config/charm.ini diff --git a/test-pack/exports/packwiz/index.toml b/test-pack/exports/packwiz/index.toml new file mode 100644 index 0000000..d2208d4 --- /dev/null +++ b/test-pack/exports/packwiz/index.toml @@ -0,0 +1,31 @@ +hash-format = "sha512" + +[[files]] +file = "mods/sodium.hm.json" +hash = "955b4e74bc9b1988cdbcfa659fb977997ebf99dbd701e4b2aa6175cacd4a763b39399ea5e16db5536cb59708e0f9ce84746c1611519c0689d640da6752b496e3" +metafile = true + +[[files]] +file = "resourcepacks/better-leaves.hm.json" +hash = "81fc1a6887ad61e4ed5662d7173efd743280bd2ad640fdebd495b30ee915bdbe9b2882b55ca7dabac155ba1e4520ad3957f76cc1ac98dce0ef7087d3c07beed9" +metafile = true + +[[files]] +file = "mods/charm.hm.json" +hash = "c388539aa188902f671e27de1bf8306ded8ed1ba7dc5cb3a9fd3b6f13895a789255d36c025b1ef2172c6123d99e28201660c83657d7d36a318c4387249c69931" +metafile = true + +[[files]] +file = "mods/fabric-api.hm.json" +hash = "c9ca7daa8ed64738ddd1fa4d334e14e768623322e25f6f8eb38ac616bbef1b3823e55136b22ff7f1e88adec98b44b2ebdb6981ccff0c7e6f1acd4ca91f82c594" +metafile = true + +[[files]] +file = "options.txt" +hash = "41ab8c11939b9379f97739cd998a44dae98f92fd3715253d14d979987a22e8ca7c5799396efc3951e16597346c590ae0bbbbf31dba9d7004c4459a3930322f20" +metafile = false + +[[files]] +file = "config/charm.ini" +hash = "21ed919c05480f55d202dc97c33ae55d897acec162ac75d5f62d914ce5a18fbaac83dc631d4690896e3b6135da1305d0f3f73e1df3b54fbc777b186367ba421d" +metafile = false \ No newline at end of file diff --git a/test-pack/exports/packwiz/mods/charm.toml b/test-pack/exports/packwiz/mods/charm.toml new file mode 100644 index 0000000..4a5412e --- /dev/null +++ b/test-pack/exports/packwiz/mods/charm.toml @@ -0,0 +1,13 @@ +name = "Charm" +filename = "charm-fabric-1.18.2-4.2.0.jar" +side = "both" + +[download] +hash-format = "sha512" +hash = "3c8cd08ab1e37dcbf0f5a956cd20d84c98e58ab49fdc13faafb9c2af4dbf7fba7c8328cb5365997fe4414cfc5cb554ed13b3056a22df1c6bd335594f380facb6" +url = "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar" + +[update] +[update.modrinth] +mod-id = "pOQTcQmj" +version = "BT9G1Jjs" \ No newline at end of file diff --git a/test-pack/exports/packwiz/mods/fabric-api.toml b/test-pack/exports/packwiz/mods/fabric-api.toml new file mode 100644 index 0000000..338d3f3 --- /dev/null +++ b/test-pack/exports/packwiz/mods/fabric-api.toml @@ -0,0 +1,13 @@ +name = "Fabric API" +filename = "fabric-api-0.58.0+1.18.2.jar" +side = "both" + +[download] +hash-format = "sha512" +hash = "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed" +url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar" + +[update] +[update.modrinth] +mod-id = "P7dR8mSH" +version = "4XRtXhtL" \ No newline at end of file diff --git a/test-pack/exports/packwiz/mods/sodium.toml b/test-pack/exports/packwiz/mods/sodium.toml new file mode 100644 index 0000000..f431ec7 --- /dev/null +++ b/test-pack/exports/packwiz/mods/sodium.toml @@ -0,0 +1,13 @@ +name = "Sodium" +filename = "sodium-fabric-mc1.18.2-0.4.1+build.15.jar" +side = "client" + +[download] +hash-format = "sha512" +hash = "86eb4db8fdb9f0bb06274c4f150b55273b5b770ffc89e0ba68011152a231b79ebe0b1adda0dd194f92cdcb386f7a60863d9fee5d15c1c3547ffa22a19083a1ee" +url = "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar" + +[update] +[update.modrinth] +mod-id = "AANobbMI" +version = "74Y5Z8fo" \ No newline at end of file diff --git a/test-pack/overrides/client/options.txt b/test-pack/exports/packwiz/options.txt similarity index 100% rename from test-pack/overrides/client/options.txt rename to test-pack/exports/packwiz/options.txt diff --git a/test-pack/exports/packwiz/pack.toml b/test-pack/exports/packwiz/pack.toml new file mode 100644 index 0000000..678351b --- /dev/null +++ b/test-pack/exports/packwiz/pack.toml @@ -0,0 +1,13 @@ +name = "Test" +author = "John Doe" +description = "A test pack for testing the horizr CLI. It is not intended for playing." +pack-format = "packwiz:1.1.0" + +[versions] +minecraft = "1.18.2" +fabric = "0.14.7" + +[index] +file = "index.toml" +hash-format = "sha512" +hash = "8173efc3a86743de3b35e88cb01fbe10f12b13ce4c99fd730abaeb81ee88d736078cc2dfcf45a12b36ded174c73ac59304f6074b2ec916babc5c941adc170299" \ No newline at end of file diff --git a/test-pack/exports/packwiz/resourcepacks/better-leaves.toml b/test-pack/exports/packwiz/resourcepacks/better-leaves.toml new file mode 100644 index 0000000..3e537cb --- /dev/null +++ b/test-pack/exports/packwiz/resourcepacks/better-leaves.toml @@ -0,0 +1,8 @@ +name = "better-leaves.hm.json" +filename = "Better-Leaves.zip" +side = "client" + +[download] +hash-format = "sha512" +hash = "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817" +url = "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip" \ No newline at end of file diff --git a/test-pack/mods/fabric-api.json b/test-pack/mods/fabric-api.json deleted file mode 100644 index 75fa0a4..0000000 --- a/test-pack/mods/fabric-api.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Fabric API", - "enabled": true, - "ignoreUpdates": false, - "side": "client-server", - "file": { - "version": "0.58.0+1.18.2", - "name": "fabric-api-0.58.0+1.18.2.jar", - "size": 1445029, - "downloadUrl": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar", - "hashes": { - "sha1": "b9ab9ab267f8cdff525f9a8edb26435d3e2455f6", - "sha512": "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed" - } - }, - "source": { - "type": "modrinth", - "modId": "P7dR8mSH", - "versionId": "4XRtXhtL" - } -} diff --git a/test-pack/mods/sodium.json b/test-pack/src/client/mods/sodium.hm.json similarity index 70% rename from test-pack/mods/sodium.json rename to test-pack/src/client/mods/sodium.hm.json index 55d1e78..437c452 100644 --- a/test-pack/mods/sodium.json +++ b/test-pack/src/client/mods/sodium.hm.json @@ -1,12 +1,9 @@ { - "name": "Sodium", "enabled": true, - "ignoreUpdates": false, - "side": "client", - "file": { - "version": "mc1.18.2-0.4.1", - "name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar", + "version": { + "name": "mc1.18.2-0.4.1", "size": 1318645, + "fileName": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar", "downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar", "hashes": { "sha1": "f839863a6be7014b8d80058ea1f361521148d049", @@ -16,6 +13,7 @@ "source": { "type": "modrinth", "modId": "AANobbMI", - "versionId": "74Y5Z8fo" + "versionId": "74Y5Z8fo", + "ignoreUpdates": false } } diff --git a/test-pack/src/client/options.txt b/test-pack/src/client/options.txt new file mode 100644 index 0000000..1e3d819 --- /dev/null +++ b/test-pack/src/client/options.txt @@ -0,0 +1 @@ +option=1 diff --git a/test-pack/src/client/resourcepacks/better-leaves.hm.json b/test-pack/src/client/resourcepacks/better-leaves.hm.json new file mode 100644 index 0000000..3c0887f --- /dev/null +++ b/test-pack/src/client/resourcepacks/better-leaves.hm.json @@ -0,0 +1,15 @@ +{ + "name": "Better Leaves", + "enabled": true, + "side": "client-server", + "version": { + "name": "7.0.0", + "fileName": "Better-Leaves.zip", + "size": 447282, + "downloadUrl": "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip", + "hashes": { + "sha1": "b768ea104fbf268ab69fcdc4004c7f36df15c545", + "sha512": "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817" + } + } +} diff --git a/test-pack/src/server/mods/lithium.hm.json b/test-pack/src/server/mods/lithium.hm.json new file mode 100644 index 0000000..4d01cab --- /dev/null +++ b/test-pack/src/server/mods/lithium.hm.json @@ -0,0 +1,19 @@ +{ + "enabled": true, + "version": { + "name": "mc1.18.2-0.7.10", + "size": 466196, + "fileName": "lithium-fabric-mc1.18.2-0.7.10.jar", + "downloadUrl": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.18.2-0.7.10/lithium-fabric-mc1.18.2-0.7.10.jar", + "hashes": { + "sha1": "d5c19c3d4edb4228652adcc8abb94f9bd80a634c", + "sha512": "05f0e51191c9051224c791d63ad4b7915e6f3c442e5d38225e7b05ea4261ee459edb3d8ce99411e1a5a854547549845f21cc8ee2f0079281fec999c1d319fb07" + } + }, + "source": { + "type": "modrinth", + "modId": "gvQqBUqZ", + "versionId": "pHl1Vi6k", + "ignoreUpdates": false + } +} diff --git a/test-pack/overrides/server/server.properties b/test-pack/src/server/server.properties similarity index 100% rename from test-pack/overrides/server/server.properties rename to test-pack/src/server/server.properties diff --git a/test-pack/src/universal/config/charm.ini b/test-pack/src/universal/config/charm.ini new file mode 100644 index 0000000..a17e4d4 --- /dev/null +++ b/test-pack/src/universal/config/charm.ini @@ -0,0 +1 @@ +test=42 diff --git a/test-pack/mods/charm.json b/test-pack/src/universal/mods/charm.hm.json similarity index 70% rename from test-pack/mods/charm.json rename to test-pack/src/universal/mods/charm.hm.json index ab801d6..6df7dad 100644 --- a/test-pack/mods/charm.json +++ b/test-pack/src/universal/mods/charm.hm.json @@ -1,12 +1,10 @@ { - "name": "Charm", + "displayName": "Charm", "enabled": true, - "ignoreUpdates": false, - "side": "client-server", - "file": { - "version": "4.2.0+1.18.2", - "name": "charm-fabric-1.18.2-4.2.0.jar", + "version": { + "name": "4.2.0+1.18.2", "size": 3413876, + "fileName": "charm-fabric-1.18.2-4.2.0.jar", "downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar", "hashes": { "sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82", @@ -16,6 +14,7 @@ "source": { "type": "modrinth", "modId": "pOQTcQmj", - "versionId": "BT9G1Jjs" + "versionId": "BT9G1Jjs", + "ignoreUpdates": false } }