From 2a9a16cb7f6d4f5d21ec68c1970370d6e509eec8 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Tue, 16 Aug 2022 22:19:24 +0200 Subject: [PATCH] Implement horizr init --- README.md | 15 ++++--- package.json | 3 +- pnpm-lock.yaml | 14 +++++++ src/commands/modrinth.ts | 29 +++++--------- src/commands/packwiz.ts | 7 +--- src/fabricApi.ts | 11 ++++++ src/files.ts | 6 +-- src/main.ts | 85 +++++++++++++++++++++++++++++++++++++++- src/modrinth/api.ts | 29 +++----------- src/modrinth/utils.ts | 2 +- src/pack.ts | 8 ++-- src/shared.ts | 1 - src/utils.ts | 22 ++++++++++- test-pack/horizr.json | 3 +- 14 files changed, 167 insertions(+), 68 deletions(-) create mode 100644 src/fabricApi.ts diff --git a/README.md b/README.md index 6dcfaf6..a9f521f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # Horizr CLI -> A CLI tool for creating Minecraft modpacks, primarily using the Fabric and Quilt loaders. +> A CLI tool for creating Minecraft modpacks using the Fabric loader. 🎉 Features: -- Search for mods on [Modrinth](https://modrinth.com/) -- Add mods from Modrinth -- View available (compatible) versions of mods from Modrinth -- View dependencies of specific mod versions +- Access [Modrinth](https://modrinth.com/) + - Search + - Add + - View available versions + - View dependencies - Check for updates and view changelogs before applying them +- Export the pack to the [Modrinth format (`.mrpack`)](https://docs.modrinth.com/docs/modpacks/format_definition/) - Export the pack to the [`packwiz`](https://packwiz.infra.link/) format - HTTP-serve the `packwiz` export for usage with [`packwiz-installer`](https://packwiz.infra.link/tutorials/installing/packwiz-installer/) -- Export the pack to the [Modrinth format (`.mrpack`)](https://docs.modrinth.com/docs/modpacks/format_definition/) ## Usage @@ -24,6 +25,8 @@ $ npm i -g @horizr/cli Run any command with the `-h` flag to see the available options. +A new pack can be initiated using `horizr init `. + ## Examples - Activate the latest (compatible) version of [Charm](https://modrinth.com/mod/charm) diff --git a/package.json b/package.json index 702b129..c72cb78 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@horizr/cli", "version": "1.0.0", - "main": "./dist/main.ts", + "main": "./dist/main.js", "type": "module", "license": "MIT", "author": "Moritz Ruth ", @@ -22,6 +22,7 @@ "address": "^1.2.0", "commander": "^9.4.0", "dedent": "^0.7.0", + "enquirer": "^2.3.6", "env-paths": "^3.0.0", "figures": "^5.0.0", "find-up": "^6.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13cd895..96e313e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ specifiers: commander: ^9.4.0 dedent: ^0.7.0 del-cli: ^5.0.0 + enquirer: ^2.3.6 env-paths: ^3.0.0 figures: ^5.0.0 find-up: ^6.3.0 @@ -44,6 +45,7 @@ dependencies: address: 1.2.0 commander: 9.4.0 dedent: 0.7.0 + enquirer: 2.3.6 env-paths: 3.0.0 figures: 5.0.0 find-up: 6.3.0 @@ -263,6 +265,11 @@ packages: indent-string: 5.0.0 dev: true + /ansi-colors/4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: false + /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} @@ -549,6 +556,13 @@ packages: once: 1.4.0 dev: false + /enquirer/2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + dependencies: + ansi-colors: 4.1.3 + dev: false + /env-paths/3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} diff --git a/src/commands/modrinth.ts b/src/commands/modrinth.ts index ab31156..8938342 100644 --- a/src/commands/modrinth.ts +++ b/src/commands/modrinth.ts @@ -20,11 +20,6 @@ import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion const modrinthCommand = new Command("modrinth") .alias("mr") - .option("--clear-cache", "Clear the cache before doing the operation.") - .on("option:clear-cache", () => { - modrinthApi.clearCache() - output.println(kleur.green("Cache was cleared.\n")) - }) modrinthCommand.command("search ") .description("Search for mods.") @@ -33,7 +28,7 @@ modrinthCommand.command("search ") .action(async (query, options) => { const pack = await usePack() const loader = output.startLoading(`Searching for ${kleur.yellow(query)}`) - const { results } = await modrinthApi.searchMods(pack.horizrFile.loader, pack.horizrFile.versions.minecraft, query, options) + const { results } = await modrinthApi.searchMods(pack.horizrFile.versions.minecraft, query, options) loader.stop() output.println( @@ -87,18 +82,14 @@ modrinthModCommand.command("versions ") const loader = output.startLoading("Fetching mod information") const modrinthMod = await modrinthApi.getMod(id) if (modrinthMod === null) return loader.failAndExit("not found") + else loader.stop() const existingMod = await findModForModrinthMod(modrinthMod) - - loader.setText("Fetching versions") - const modrinthVersions = await modrinthApi.listVersions(id, pack.horizrFile.loader, pack.horizrFile.versions.minecraft) - loader.stop() + const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions") if (modrinthVersions.length === 0) { - const message = dedent` - There are no versions compatible with the pack (Loader: ${kleur.yellow(pack.horizrFile.loader)}, \ - Minecraft ${kleur.yellow(pack.horizrFile.versions.minecraft)}). - ` + const message = + `There are no versions compatible with the pack (Fabric ${kleur.yellow(pack.horizrFile.versions.fabric)}, Minecraft ${kleur.yellow(pack.horizrFile.versions.minecraft)}).` output.println(kleur.red(message)) } else { @@ -138,11 +129,9 @@ modrinthModCommand.command("activate ") const loader = output.startLoading("Fetching mod information") const modrinthMod = await modrinthApi.getMod(id) if (modrinthMod === null) return loader.failAndExit("not found") + else loader.stop() - loader.setText("Fetching versions") - const modrinthVersions = await modrinthApi.listVersions(id, pack.horizrFile.loader, pack.horizrFile.versions.minecraft) - loader.stop() - + const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions") if (modrinthVersions.length === 0) return output.failAndExit("There is no compatible version of this mod.") const sortedModrinthVersions = sortModrinthVersionsByPreference(modrinthVersions) @@ -238,7 +227,7 @@ modrinthVersionCommand.command("info ") Version name: ${kleur.yellow(modrinthVersion.name)} ${kleur.gray(ago(modrinthVersion.publicationDate))} Minecraft versions: ${modrinthVersion.supportedMinecraftVersions.map(version => version === pack.horizrFile.versions.minecraft ? kleur.green(version) : kleur.red(version)).join(", ")} - Loaders: ${modrinthVersion.supportedLoaders.map(loader => loader === pack.horizrFile.loader ? kleur.green(loader) : kleur.red(loader)).join(", ")} + Loaders: ${modrinthVersion.supportedLoaders.map(loader => loader === "fabric" ? kleur.green(loader) : kleur.red(loader)).join(", ")} Related mods: ${relationsColorKey} ${relationsList} @@ -298,7 +287,7 @@ modrinthCommand.command("export") summary: pack.horizrFile.meta.description, dependencies: { minecraft: pack.horizrFile.versions.minecraft, - [`${pack.horizrFile.loader}-loader`]: pack.horizrFile.versions.loader + "fabric-loader": pack.horizrFile.versions.fabric }, files: pack.mods.map(mod => ({ path: `mods/${mod.modFile.file.name}`, diff --git a/src/commands/packwiz.ts b/src/commands/packwiz.ts index 8ac6b7e..9af2c31 100644 --- a/src/commands/packwiz.ts +++ b/src/commands/packwiz.ts @@ -50,9 +50,6 @@ packwizCommand.command("export") async function runExport(forServer: boolean) { const pack = await usePack() - if (pack.horizrFile.loader !== "fabric") - output.println(kleur.yellow(`packwiz does not yet support the ${kleur.reset(pack.horizrFile.loader)} loader. No loader will be specified.`)) - const loader = output.startLoading("Generating") const outputDirectoryPath = pack.paths.generated.resolve("packwiz") @@ -172,8 +169,8 @@ async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDire pack-format = "packwiz:1.0.0" [versions] - minecraft = "${pack.horizrFile.versions.minecraft}"\ - ${pack.horizrFile.loader === "fabric" ? "\n" + `fabric = ${JSON.stringify(pack.horizrFile.versions.loader)}` : ""} + minecraft = ${JSON.stringify(pack.horizrFile.versions.minecraft)} + fabric = ${JSON.stringify(pack.horizrFile.versions.fabric)} [index] file = "index.toml" diff --git a/src/fabricApi.ts b/src/fabricApi.ts new file mode 100644 index 0000000..5d2993a --- /dev/null +++ b/src/fabricApi.ts @@ -0,0 +1,11 @@ +import { got } from "./utils.js" + +export async function fetchFabricMinecraftVersions(): Promise { + const versions = await got("https://meta.fabricmc.net/v1/versions/game").json() + return versions.map(version => version.version as string) +} + +export async function fetchFabricVersions(minecraftVersion: string): Promise { + const versions = await got(`https://meta.fabricmc.net/v1/versions/loader/${minecraftVersion}`).json() + return versions.map(version => version.loader.version) +} diff --git a/src/files.ts b/src/files.ts index a6000fd..775bc91 100644 --- a/src/files.ts +++ b/src/files.ts @@ -59,19 +59,19 @@ const horizrFileSchema = z.object({ description: z.string().optional(), license: z.string() }), - loader: z.enum(["fabric", "quilt"]), versions: z.object({ minecraft: z.string(), - loader: z.string() + fabric: z.string() }) }) export type HorizrFile = z.output +export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1 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 !== 1) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`) + if (data.formatVersion !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`) return data } diff --git a/src/main.ts b/src/main.ts index 54d9d15..c763cfd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,19 +6,101 @@ import { modrinthCommand } from "./commands/modrinth.js" import { packwizCommand } from "./commands/packwiz.js" import dedent from "dedent" import { default as wrapAnsi } from "wrap-ansi" -import { removeModFile } from "./files.js" +import { CURRENT_HORIZR_FILE_FORMAT_VERSION, HorizrFile, removeModFile } from "./files.js" import { output } from "./output.js" import figures from "figures" import yesno from "yesno" 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" const program = new Command("horizr") .version( (await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version, "-v, --version" ) + .option("--clear-cache", "Clear the HTTP cache before doing the operation.") + .on("option:clear-cache", () => { + clearCache() + 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.") @@ -35,7 +117,6 @@ program.command("info", { isDefault: true }) License: ${kleur.yellow(pack.horizrFile.meta.license.toUpperCase())} Mods: ${kleur.yellow(pack.mods.length.toString())}${disabledModsCount === 0 ? "" : ` (${disabledModsCount} disabled)`} - Loader: ${kleur.yellow(`${pack.horizrFile.loader} v${pack.horizrFile.versions.loader}`)} Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)} `) }) diff --git a/src/modrinth/api.ts b/src/modrinth/api.ts index eccf5d7..927b709 100644 --- a/src/modrinth/api.ts +++ b/src/modrinth/api.ts @@ -1,19 +1,9 @@ -import got, { HTTPError, Response } from "got" +import { HTTPError, Response } from "got" import kleur from "kleur" -import { KeyvFile } from "keyv-file" -import { delay } from "../utils.js" +import { delay, got } from "../utils.js" import { output } from "../output.js" -import { paths } from "../path.js" import { dependencyToRelatedVersionType } from "./utils.js" -import { ModLoader, ReleaseChannel } from "../shared.js" - -const keyvCache = new KeyvFile({ - filename: paths.cache.resolve("http.json").toString(), - writeDelay: 50, - expiredCheckDelay: 24 * 3600 * 1000, - encode: JSON.stringify, - decode: JSON.parse -}) +import { ReleaseChannel } from "../shared.js" async function getModrinthApiOptional(url: string): Promise { let response: Response @@ -21,11 +11,6 @@ async function getModrinthApiOptional(url: string): Promise { while (true) { response = await got(url, { prefixUrl: "https://api.modrinth.com", - headers: { - "User-Agent": "moritzruth/horizr/1.0.0 (not yet public)" - }, - cache: keyvCache, - responseType: "json", throwHttpErrors: false, retry: { limit: 3, @@ -159,14 +144,12 @@ export interface ModrinthVersionFile { } export const modrinthApi = { - clearCache: () => keyvCache.clear(), async searchMods( - loader: ModLoader, minecraftVersion: string, query: string, pagination: PaginationOptions ): Promise<{ total: number; results: ModrinthMod[] }> { - const facets = `[["categories:${loader}"],["versions:${minecraftVersion}"],["project_type:mod"]]` + const facets = `[["categories:fabric"],["versions:${minecraftVersion}"],["project_type:mod"]]` const response = await getModrinthApi(`v2/search?query=${encodeURIComponent(query)}&limit=${pagination.limit}&offset=${pagination.skip}&facets=${facets}`) @@ -205,8 +188,8 @@ export const modrinthApi = { updateDate: new Date(response.updated) } }, - async listVersions(idOrSlug: string, loader: ModLoader, minecraftVersion: string): Promise { - const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`) + async listVersions(idOrSlug: string, minecraftVersion: string): Promise { + const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`) return response.map(transformApiModVersion) }, diff --git a/src/modrinth/utils.ts b/src/modrinth/utils.ts index 67413e4..71cf859 100644 --- a/src/modrinth/utils.ts +++ b/src/modrinth/utils.ts @@ -29,7 +29,7 @@ export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<( } export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) => - modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes(pack.horizrFile.loader) + modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric") export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData { const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files) diff --git a/src/pack.ts b/src/pack.ts index e4d7764..400ace0 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -75,10 +75,12 @@ export async function usePack(): Promise { 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.`) + 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.loader, pack.horizrFile.versions.minecraft) + 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 => { diff --git a/src/shared.ts b/src/shared.ts index d923840..fb270da 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,4 +1,3 @@ -export type ModLoader = "fabric" | "quilt" export type ReleaseChannel = "alpha" | "beta" | "release" export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"] diff --git a/src/utils.ts b/src/utils.ts index 45aced1..34918ed 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { InvalidArgumentError } from "commander" import hash, { HashaInput } from "hasha" -import { Path } from "./path.js" +import { Path, paths } from "./path.js" import { ZipFile } from "yazl" import { walk } from "@root/walk" import fs from "fs-extra" @@ -9,6 +9,26 @@ 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" + +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) diff --git a/test-pack/horizr.json b/test-pack/horizr.json index 8396ba3..f08aee3 100644 --- a/test-pack/horizr.json +++ b/test-pack/horizr.json @@ -7,9 +7,8 @@ "description": "A test pack for testing the horizr CLI. It is not intended for playing.", "license": "MIT" }, - "loader": "fabric", "versions": { - "loader": "0.14.7", + "fabric": "0.14.7", "minecraft": "1.18.2" } }