Implement horizr init

This commit is contained in:
Moritz Ruth 2022-08-16 22:19:24 +02:00
parent 1fbb3e2142
commit 2a9a16cb7f
14 changed files with 167 additions and 68 deletions

View file

@ -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 <path>`.
## Examples
- Activate the latest (compatible) version of [Charm](https://modrinth.com/mod/charm)

View file

@ -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 <dev@moritzruth.de>",
@ -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",

14
pnpm-lock.yaml generated
View file

@ -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}

View file

@ -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 <query...>")
.description("Search for mods.")
@ -33,7 +28,7 @@ modrinthCommand.command("search <query...>")
.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 <id>")
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 <id>")
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 <id>")
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}`,

View file

@ -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"

11
src/fabricApi.ts Normal file
View file

@ -0,0 +1,11 @@
import { got } from "./utils.js"
export async function fetchFabricMinecraftVersions(): Promise<string[]> {
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()
return versions.map(version => version.version as string)
}
export async function fetchFabricVersions(minecraftVersion: string): Promise<string[]> {
const versions = await got(`https://meta.fabricmc.net/v1/versions/loader/${minecraftVersion}`).json<any[]>()
return versions.map(version => version.loader.version)
}

View file

@ -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<typeof horizrFileSchema>
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
}

View file

@ -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 <path>")
.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)}
`)
})

View file

@ -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<any | null> {
let response: Response
@ -21,11 +11,6 @@ async function getModrinthApiOptional(url: string): Promise<any | null> {
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<ModrinthVersion[]> {
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`)
async listVersions(idOrSlug: string, minecraftVersion: string): Promise<ModrinthVersion[]> {
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`)
return response.map(transformApiModVersion)
},

View file

@ -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)

View file

@ -75,10 +75,12 @@ export async function usePack(): Promise<Pack> {
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 => {

View file

@ -1,4 +1,3 @@
export type ModLoader = "fabric" | "quilt"
export type ReleaseChannel = "alpha" | "beta" | "release"
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]

View file

@ -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)

View file

@ -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"
}
}