initial commit

This commit is contained in:
Moritz Ruth 2022-08-16 00:24:29 +02:00
commit fd56aa7aa0
16 changed files with 2385 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
.idea/
dist/

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2022 Moritz Ruth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

40
docs/horizr.md Normal file
View file

@ -0,0 +1,40 @@
# 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.

56
package.json Normal file
View file

@ -0,0 +1,56 @@
{
"name": "horizr-cli",
"version": "1.0.0",
"private": true,
"main": "./src/main.ts",
"type": "module",
"license": "MIT",
"scripts": {
"start": "tsx .",
"build": "tsc"
},
"oclif": {
"bin": "horizr",
"dirname": "horizr",
"commands": "./dist/commands",
"plugins": [
"@oclif/plugin-plugins"
],
"topicSeparator": " "
},
"bin": {
"horizr": "./bin/horizr"
},
"dependencies": {
"commander": "^9.4.0",
"dedent": "^0.7.0",
"env-paths": "^3.0.0",
"figures": "^5.0.0",
"find-up": "^6.3.0",
"fs-extra": "^10.1.0",
"got": "^12.3.1",
"hasha": "^5.2.2",
"keyv-file": "^0.2.0",
"kleur": "^4.1.5",
"lodash-es": "^4.17.21",
"loud-rejection": "^2.2.0",
"nanoid": "^4.0.0",
"ora": "^6.1.2",
"p-limit": "^4.0.0",
"s-ago": "^2.2.0",
"semver": "^7.3.7",
"wrap-ansi": "^8.0.1",
"zod": "^3.18.0"
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"@types/fs-extra": "^9.0.13",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.7.3",
"@types/semver": "^7.3.12",
"@types/wrap-ansi": "^8.0.1",
"tsx": "^3.8.2",
"type-fest": "^2.18.0",
"typescript": "^4.7.4"
}
}

1002
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

317
src/commands/modrinth.ts Normal file
View file

@ -0,0 +1,317 @@
import { Command } from "commander"
import { take } from "lodash-es"
import { usePack } from "../pack.js"
import kleur from "kleur"
import { optionParsePositiveInteger, truncateWithEllipsis } from "../utils.js"
import { default as wrapAnsi } from "wrap-ansi"
import figures from "figures"
import {
addModrinthMod,
findModForModrinthMod,
getModFileDataForModrinthVersion, isModrinthVersionCompatible,
modrinthApi,
ModrinthMod,
ModrinthVersion,
ModrinthVersionRelation,
sortModrinthVersionsByPreference
} from "../modrinth.js"
import dedent from "dedent"
import ago from "s-ago"
import semver from "semver"
import { output } from "../output.js"
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.")
.option("-l, --limit <number>", "Limit the number of results", optionParsePositiveInteger, 8)
.option("-s, --skip <number>", "Skip results", optionParsePositiveInteger, 0)
.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)
loader.stop()
output.println(
results.map(result =>
`${kleur.blue(result.id)} ${kleur.bold(truncateWithEllipsis(result.title, 30))} ${kleur.gray(`(↓ ${result.downloadsCount})`)}\n` +
wrapAnsi(result.description, process.stdout.columns)
)
.join("\n\n")
)
})
const colorBySideCompatibility: Record<ModrinthMod["clientSide"], kleur.Color> = {
optional: kleur.blue,
required: kleur.green,
unsupported: kleur.red
}
const modrinthModCommand = modrinthCommand.command("mod")
modrinthModCommand.command("info <id>")
.description("Show information about the mod.")
.action(async id => {
const loader = output.startLoading("Fetching mod information")
const modrinthMod = await modrinthApi.getMod(id)
if (modrinthMod === null) return loader.failAndExit("not found")
loader.stop()
const existingMod = await findModForModrinthMod(modrinthMod)
output.println(dedent`
${kleur.bold(modrinthMod.title)} ${kleur.gray(`(↓ ${modrinthMod.downloadsCount})`)}
${wrapAnsi(modrinthMod.description, process.stdout.columns)}
Client Server
${colorBySideCompatibility[modrinthMod.clientSide](modrinthMod.clientSide.padEnd(12, " "))} ${colorBySideCompatibility[modrinthMod.serverSide](modrinthMod.serverSide)}
License: ${kleur.yellow(modrinthMod.licenseCode.toUpperCase())}
Last update: ${kleur.yellow(ago(modrinthMod.updateDate))}\
${existingMod === null ? "" : kleur.green("\n\nThis mod is in the pack.")}
https://modrinth.com/mod/${modrinthMod.slug}
`)
})
modrinthModCommand.command("versions <id>")
.description("Show a list of compatible versions of the mod.")
.option("-l, --limit <number>", "Limit the number of versions displayed.", optionParsePositiveInteger, 3)
.action(async (id, options) => {
const pack = await usePack()
const loader = output.startLoading("Fetching mod information")
const modrinthMod = await modrinthApi.getMod(id)
if (modrinthMod === null) return loader.failAndExit("not found")
const existingMod = await findModForModrinthMod(modrinthMod)
loader.setText("Fetching versions")
const modrinthVersions = await modrinthApi.listVersions(id, pack.horizrFile.loader, pack.horizrFile.versions.minecraft)
loader.stop()
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)}).
`
output.println(kleur.red(message))
} else {
const versions = take(sortModrinthVersionsByPreference(modrinthVersions), options.limit)
.map(modrinthVersion => {
const state = existingMod !== null && existingMod.modFile.source.versionId === modrinthVersion.id
? kleur.bgGreen().black(" active ") + "\n\n"
: modrinthVersion.isFeatured
? kleur.green("featured") + "\n\n"
: ""
return dedent`
${kleur.blue(modrinthVersion.id)} ${kleur.bold(modrinthVersion.versionString)} ${kleur.gray(`(↓ ${modrinthVersion.downloadsCount})`)}
${state}\
${modrinthVersion.name !== modrinthVersion.versionString ? `Name: ${kleur.yellow(modrinthVersion.name)}\n` : ""}\
Channel: ${kleur.yellow(modrinthVersion.releaseChannel)}
Minecraft versions: ${kleur.yellow(modrinthVersion.supportedMinecraftVersions.join(", "))}
Publication: ${kleur.yellow(ago(modrinthVersion.publicationDate))}
https://modrinth.com/mod/${modrinthVersion.projectId}/version/${modrinthVersion.id}
`
})
.join("\n\n")
output.println(versions)
}
})
modrinthModCommand.command("activate <id>")
.description("Activate the recommended version of the mod.")
.alias("a")
.option("-f, --force", "Replace a different version already active.")
.action(async (id, options) => {
const pack = await usePack()
const loader = output.startLoading("Fetching mod information")
const modrinthMod = await modrinthApi.getMod(id)
if (modrinthMod === null) return loader.failAndExit("not found")
loader.setText("Fetching versions")
const modrinthVersions = await modrinthApi.listVersions(id, pack.horizrFile.loader, pack.horizrFile.versions.minecraft)
loader.stop()
if (modrinthVersions.length === 0) return output.failAndExit("There is no compatible version of this mod.")
const sortedModrinthVersions = sortModrinthVersionsByPreference(modrinthVersions)
const modrinthVersion = sortedModrinthVersions[0]
await handleActivate(modrinthMod, modrinthVersion, options.force)
})
const colorByRelationType: Record<ModrinthVersionRelation["type"], kleur.Color> = {
"embedded_dependency": kleur.green,
"soft_dependency": kleur.magenta,
"hard_dependency": kleur.yellow,
"incompatible": kleur.red
}
const nullVersionStringByRelationType: Record<ModrinthVersionRelation["type"], string> = {
"embedded_dependency": "unknown version",
"soft_dependency": "any version",
"hard_dependency": "any version",
"incompatible": "all versions"
}
const versionStateStrings = {
"active": kleur.bgGreen().black(" active "),
"compatible": kleur.blue("compatible"),
"incompatible": kleur.red("incompatible"),
"newer_version": `${kleur.bgYellow().black(" older version active ")} ${figures.arrowRight} EXISTING_VERSION`,
"older_version": `${kleur.bgYellow().black(" newer version active ")} ${figures.arrowRight} EXISTING_VERSION`,
"different_version": `${kleur.bgYellow().black(" different version active ")} ${figures.arrowRight} EXISTING_VERSION`
}
async function getRelationsListLines(relations: ModrinthVersionRelation[]) {
return await Promise.all(relations.map(async relation => {
const color = colorByRelationType[relation.type]
const relatedVersion = relation.versionId === null ? null : (await modrinthApi.getVersion(relation.versionId))
const versionString = relatedVersion === null ? nullVersionStringByRelationType[relation.type] : relatedVersion.versionString
const relatedMod = (await modrinthApi.getMod(relation.projectId === null ? relatedVersion!.projectId : relation.projectId))!
return `${color(figures.circleFilled)} ${relatedMod.title}${relation.projectId ? ` (${kleur.blue(relation.projectId)})` : ""}: ` +
`${versionString}${relation.versionId ? ` (${kleur.blue(relation.versionId)})` + " " : ""}`
}))
}
const modrinthVersionCommand = modrinthCommand.command("version")
modrinthVersionCommand.command("info <id>")
.description("Show information about the version.")
.action(async id => {
const pack = await usePack()
const loader = output.startLoading("Fetching version information")
const modrinthVersion = await modrinthApi.getVersion(id)
if (modrinthVersion === null) return loader.failAndExit("not found")
loader.setText("Fetching mod information")
const modrinthMod = (await modrinthApi.getMod(modrinthVersion.projectId))!
const existingMod = await findModForModrinthMod(modrinthMod)
let state: keyof typeof versionStateStrings
if (existingMod === null) state = isModrinthVersionCompatible(modrinthVersion, pack) ? "compatible" : "incompatible"
else {
if (existingMod.modFile.source.versionId === modrinthVersion.id) state = "active"
else {
const existingSemver = semver.parse(existingMod.modFile.file.version)
const newSemver = semver.parse(modrinthVersion.versionString)
if (existingSemver === null || newSemver === null) state = "different_version"
else {
const comparison = newSemver.compare(existingSemver)
if (comparison === 1) state = "newer_version"
else if (comparison === -1) state = "older_version"
else state = "active" // this should not happen: the versionString is the same but the versionId is different
}
}
}
loader.setText("Resolving relations")
const relationsList = modrinthVersion.relations.length !== 0 ? (await getRelationsListLines(modrinthVersion.relations)).join("\n") : kleur.gray("none")
const relationsColorKey = `${colorByRelationType.hard_dependency("hard dependency")}, ${colorByRelationType.soft_dependency("soft dependency")}, ` +
`${colorByRelationType.embedded_dependency("embedded")}, ${colorByRelationType.incompatible("incompatible")}`
loader.stop()
output.println(dedent`
${kleur.underline(modrinthMod.title)} ${kleur.yellow(`${modrinthVersion.versionString} (${modrinthVersion.releaseChannel})`)}
${versionStateStrings[state].replace("EXISTING_VERSION", existingMod?.modFile?.file.version ?? "ERROR")}
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(", ")}
Related mods: ${relationsColorKey}
${relationsList}
https://modrinth.com/mod/${modrinthMod.slug}/version/${modrinthVersion.versionString}
`)
})
modrinthVersionCommand.command("activate <id>")
.description("Activate the mod version.")
.alias("a")
.option("-f, --force", "Replace a different version already active.")
.action(async (id, options) => {
const pack = await usePack()
const loader = output.startLoading("Fetching version information")
const modrinthVersion = await modrinthApi.getVersion(id)
if (modrinthVersion === null) return loader.failAndExit("not found")
loader.setText("Fetching mod information")
const modrinthMod = (await modrinthApi.getMod(modrinthVersion.projectId))!
loader.stop()
if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.")
await handleActivate(modrinthMod, modrinthVersion, options.force)
})
modrinthVersionCommand.command("export")
.description("Generate a Modrinth pack file suitable for uploading")
.action(async () => {
// TODO: Implement export
})
async function handleActivate(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, force: boolean) {
const existingMod = await findModForModrinthMod(modrinthMod)
if (existingMod === null) {
await addModrinthMod(modrinthMod, modrinthVersion)
output.println(`${modrinthMod.title} (${modrinthVersion.versionString}) ${kleur.green("was successfully activated.")}\n`)
await handleDependencies(modrinthVersion.relations)
} else {
const oldVersion = existingMod.modFile.file.version
if (existingMod.modFile.source.versionId === modrinthVersion.id) {
output.println(kleur.green("This version is already installed."))
} else if (force) {
existingMod.modFile.file = getModFileDataForModrinthVersion(modrinthMod, modrinthVersion)
existingMod.modFile.source.versionId = modrinthVersion.id
await existingMod.saveModFile()
output.println(`${kleur.green("Successfully replaced version")} ${oldVersion} ${kleur.green("of")} ${modrinthMod.title} ${kleur.green("with")} ${modrinthVersion.versionString}${kleur.green(".")}`)
await handleDependencies(modrinthVersion.relations)
} else {
output.failAndExit(`There is already a different version of this mod installed.\nRun this command again with ${kleur.yellow("-f")} to change the version.`)
}
}
}
async function handleDependencies(relations: ModrinthVersionRelation[]) {
const loader = output.startLoading("Fetching dependency information")
const lines = await getRelationsListLines(relations.filter(relation => relation.type === "hard_dependency" || relation.type === "soft_dependency"))
if (lines.length !== 0) {
output.println(dedent`
\n${kleur.underline("Dependencies")} ${colorByRelationType.hard_dependency("hard")}, ${colorByRelationType.soft_dependency("soft")}
${lines.join("\n")}
`)
}
loader.stop()
}
export { modrinthCommand }

94
src/commands/packwiz.ts Normal file
View file

@ -0,0 +1,94 @@
import { Command } from "commander"
import { usePack } from "../pack.js"
import fs from "fs-extra"
import dedent from "dedent"
import kleur from "kleur"
import { relative } from "path"
import { getSha512HexHash } from "../utils.js"
import { output } from "../output.js"
const packwizCommand = new Command("packwiz")
interface IndexedFile {
path: string
sha512HashHex: string
isMeta: boolean
}
packwizCommand.command("export")
.description("Generates a packwiz pack in the packwiz directory")
.action(async () => {
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 rootDirectoryPath = pack.resolvePath("packwiz")
await fs.remove(rootDirectoryPath)
await fs.mkdirp(pack.resolvePath("packwiz/mods"))
const indexedFiles: IndexedFile[] = []
for (const mod of pack.mods) {
if (!mod.modFile.enabled) output.warn(`${kleur.yellow(mod.modFile.name)} is disabled and will not be included.`)
const innerLoader = output.startLoading(`Generating ${kleur.yellow(mod.id + ".toml")} (${indexedFiles.length + 1}/${pack.mods.length})`)
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 = "${mod.modFile.file.hashAlgorithm}"
hash = ${JSON.stringify(mod.modFile.file.hash)}
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
`
const path = pack.resolvePath("packwiz/mods", mod.id + ".toml")
await fs.writeFile(path, content)
indexedFiles.push({
path: relative(rootDirectoryPath, path),
isMeta: true,
sha512HashHex: await getSha512HexHash(content)
})
innerLoader.stop()
}
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(pack.resolvePath("packwiz/index.toml"), index)
const indexHash = await getSha512HexHash(index)
await fs.writeFile(pack.resolvePath("packwiz/pack.toml"), 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 = "${pack.horizrFile.versions.minecraft}"\
${pack.horizrFile.loader === "fabric" ? "\n" + `fabric = ${JSON.stringify(pack.horizrFile.versions.loader)}` : ""}
[index]
file = "index.toml"
hash-format = "sha512"
hash = "${indexHash}"
`)
loader.stop()
output.println(kleur.green("Successfully generated packwiz pack."))
})
export { packwizCommand}

128
src/files.ts Normal file
View file

@ -0,0 +1,128 @@
import { SafeParseError, z, ZodRawShape } from "zod"
import kleur from "kleur"
import fs from "fs-extra"
import * as process from "process"
import { resolve, dirname } from "path"
import { findUp } from "find-up"
import { output } from "./output.js"
export async function findPackDirectoryPath() {
if (process.argv0.endsWith("/node")) { // run using pnpm
return resolve(process.cwd(), "./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.`)
return dirname(parent)
}
}
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
packPath: string,
filePath: string,
schema: S
): Promise<z.output<S> | null> {
let data
try {
data = await fs.readJson(resolve(packPath, filePath))
} catch (e: unknown) {
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath)} does not contain valid JSON.`)
else return null
}
const result = await schema.safeParseAsync(data)
if (!result.success) {
const error = (result as SafeParseError<unknown>).error
return output.failAndExit(`${kleur.yellow(filePath)} 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<S extends z.ZodObject<ZodRawShape>>(packPath: string, filePath: string, schema: S, data: z.input<S>) {
const absolutePath = resolve(packPath, filePath)
await fs.mkdirp(dirname(absolutePath))
await fs.writeJson(absolutePath, schema.parse(data), { spaces: 2 })
}
const horizrFileSchema = z.object({
formatVersion: z.string().or(z.number()),
meta: z.object({
name: z.string(),
version: z.string(),
authors: z.array(z.string()),
description: z.string().optional(),
license: z.string()
}),
loader: z.enum(["fabric", "quilt"]),
versions: z.object({
minecraft: z.string(),
loader: z.string()
})
})
export type HorizrFile = z.output<typeof horizrFileSchema>
export async function readHorizrFile(packPath: string) {
const data = await readJsonFileInPack(packPath, "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)}`)
return data
}
const modFileModrinthSourceSchema = z.object({
type: z.literal("modrinth"),
modId: z.string(),
versionId: z.string()
})
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema>
const modFileDataSchema = z.object({
version: z.string(),
name: z.string(),
size: z.number().int().min(0).optional(),
downloadUrl: z.string().url(),
hashAlgorithm: z.enum(["sha1", "sha256", "sha512"]),
hash: z.string()
})
export type ModFileData = z.output<typeof modFileDataSchema>
const modFileSchema = z.object({
name: z.string(),
enabled: z.boolean().default(true),
ignoreUpdates: z.boolean().default(false),
side: z.enum(["client", "server", "client+server"]),
comment: z.string().optional(),
file: modFileDataSchema,
source: z.discriminatedUnion("type", [
modFileModrinthSourceSchema,
z.object({ type: z.literal("raw") })
])
})
export type ModFile = z.output<typeof modFileSchema>
export async function readModFile(packPath: string, modId: string): Promise<ModFile | null> {
return await readJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema)
}
export async function writeModFile(packPath: string, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
await writeJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema, data)
}
export async function removeModFile(packPath: string, modId: string): Promise<void> {
await fs.remove(resolve(packPath, `mods/${modId}.json`))
}
export async function readModIds(packPath: string) {
const modsPath = resolve(packPath, "./mods")
await fs.mkdirp(modsPath)
const files = await fs.readdir(modsPath, { withFileTypes: true })
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
}

95
src/main.ts Normal file
View file

@ -0,0 +1,95 @@
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 { removeModFile } from "./files.js"
import { output } from "./output.js"
import figures from "figures"
import { releaseChannelOrder } from "./modrinth.js"
const program = new Command("horizr")
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)`}
Loader: ${kleur.yellow(`${pack.horizrFile.loader} v${pack.horizrFile.versions.loader}`)}
Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)}
`)
})
program.command("remove <code>")
.description("Remove the mod from the pack.")
.action(async code => {
const pack = await usePack()
const mod = pack.findModByCodeOrFail(code)
await removeModFile(pack.path, 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 loader = output.startLoading("Checking for an update")
const mod = pack.findModByCodeOrFail(code)
const update = await mod.checkForUpdate(allowedReleaseChannels)
if (update === null) {
loader.stop()
output.println(kleur.green("No update available."))
} else {
loader.setText("Updating")
await update.apply()
loader.stop()
output.println(kleur.green(`Successfully updated ${kleur.yellow(update.mod.modFile.name)} to ${kleur.yellow(update.availableVersion)}.`))
}
}
})
loudRejection()
program
.addCommand(packwizCommand)
.addCommand(modrinthCommand)
.addHelpText("afterAll", "\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:")}
`)
.parse(process.argv)

284
src/modrinth.ts Normal file
View file

@ -0,0 +1,284 @@
import { IterableElement } from "type-fest"
import originalGot, { HTTPError, Response } from "got"
import { sortBy } from "lodash-es"
import { Loader, Mod, Pack, usePack } from "./pack.js"
import { ModFile, ModFileData, ModFileModrinthSource } from "./files.js"
import { pathExists } from "fs-extra"
import kleur from "kleur"
import { nanoid } from "nanoid/non-secure"
import { KeyvFile } from "keyv-file"
import { resolve } from "path"
import { delay, paths } from "./utils.js"
import { output } from "./output.js"
const keyvCache = new KeyvFile({
filename: resolve(paths.cache, "http.json"),
writeDelay: 50,
expiredCheckDelay: 24 * 3600 * 1000,
encode: JSON.stringify,
decode: JSON.parse
})
const got = originalGot.extend()
async function getModrinthApiOptional(url: string): Promise<any | null> {
let response: Response
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
})
if (response.statusCode.toString().startsWith("2")) {
// success
return response.body
} else if (response.statusCode === 404) {
// not found
return null
} else if (response.statusCode === 429) {
// rate limited
const secondsUntilReset = Number(response.headers["x-ratelimit-reset"])
output.warn(`Rate-limit exceeded. Retrying in ${kleur.yellow(secondsUntilReset)} seconds…`)
await delay(secondsUntilReset * 1000)
} else {
output.failAndExit(`A request to the Modrinth API failed with status code ${kleur.yellow(response.statusCode)}.`)
}
}
}
async function getModrinthApi(url: string): Promise<any> {
const response = await getModrinthApiOptional(url)
if (response === null) return output.failAndExit("Request failed with status code 404.")
return response
}
const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
required: "hard_dependency",
optional: "soft_dependency",
embedded: "embedded_dependency",
incompatible: "incompatible"
}
export type ReleaseChannel = "alpha" | "beta" | "release"
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
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(pack.horizrFile.loader)
export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData {
const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files)
return {
version: modrinthModVersion.versionString,
hash: modrinthVersionFile.hashes.sha512,
hashAlgorithm: "sha512",
downloadUrl: modrinthVersionFile.url,
name: modrinthVersionFile.fileName,
size: modrinthVersionFile.sizeInBytes,
}
}
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion) {
const pack = await usePack()
let id = modrinthMod.slug
if (await pathExists(pack.resolvePath("mods", `${id}.json`))) {
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`)}`
)
}
const isClientSupported = modrinthMod.clientSide !== "unsupported"
const isServerSupported = modrinthMod.serverSide !== "unsupported"
await pack.addMod(id, {
name: modrinthMod.title,
enabled: true,
ignoreUpdates: false,
side: isClientSupported && isServerSupported ? "client+server" : isClientSupported ? "client" : "server",
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]
}
function transformApiModVersion(raw: any): ModrinthVersion {
return {
id: raw.id,
projectId: raw.project_id,
name: raw.name,
versionString: raw.version_number,
releaseChannel: raw.version_type,
isFeatured: raw.featured,
publicationDate: new Date(raw.date_published),
changelog: raw.changelog,
supportedMinecraftVersions: raw.game_versions,
supportedLoaders: raw.loaders,
downloadsCount: raw.downloads,
relations: raw.dependencies.map((dependency: any): ModrinthVersionRelation => ({
type: dependencyToRelatedVersionType[dependency.dependency_type],
versionId: dependency.version_id,
projectId: dependency.project_id
})),
files: raw.files.map((file: any): ModrinthVersionFile => ({
isPrimary: file.primary,
hashes: file.hashes,
fileName: file.filename,
url: file.url,
sizeInBytes: file.size
}))
}
}
export interface PaginationOptions {
limit: number
skip: number
}
type ProjectOrVersionId = {
versionId: string
projectId: string | null
} | {
versionId: string | null
projectId: string
}
export interface ModrinthMod {
id: string
slug: string
title: string
description: string
categories: string[]
clientSide: "required" | "optional" | "unsupported"
serverSide: "required" | "optional" | "unsupported"
downloadsCount: number
licenseCode: string
creationDate: Date
updateDate: Date
}
export type ModrinthVersionRelation = ProjectOrVersionId & {
type: "hard_dependency" | "soft_dependency" | "embedded_dependency" | "incompatible"
}
export interface ModrinthVersion {
id: string
projectId: string
name: string
versionString: string
releaseChannel: ReleaseChannel
isFeatured: boolean
publicationDate: Date
changelog: string | null
supportedMinecraftVersions: string[]
supportedLoaders: string[]
downloadsCount: number
relations: ModrinthVersionRelation[]
files: ModrinthVersionFile[]
}
export interface ModrinthVersionFile {
hashes: Record<"sha512" | "sha1", string>
url: string
fileName: string
isPrimary: boolean
sizeInBytes: number
}
export const modrinthApi = {
clearCache: () => keyvCache.clear(),
async searchMods(
loader: Loader,
minecraftVersion: string,
query: string,
pagination: PaginationOptions
): Promise<{ total: number; results: ModrinthMod[] }> {
const facets = `[["categories:${loader}"],["versions:${minecraftVersion}"],["project_type:mod"]]`
const response = await getModrinthApi(`v2/search?query=${encodeURIComponent(query)}&limit=${pagination.limit}&offset=${pagination.skip}&facets=${facets}`)
return {
total: response.total_hits,
results: response.hits.map((hit: any): ModrinthMod => ({
id: hit.project_id,
slug: hit.slug,
title: hit.title,
description: hit.description,
categories: hit.categories,
clientSide: hit.client_side,
serverSide: hit.server_side,
downloadsCount: hit.downloads,
licenseCode: hit.license,
creationDate: new Date(hit.date_created),
updateDate: new Date(hit.date_modified)
}))
}
},
async getMod(idOrSlug: string): Promise<ModrinthMod | null> {
const response = await getModrinthApiOptional(`v2/project/${idOrSlug}`)
if (response === null) return null
return {
id: response.id,
slug: response.slug,
title: response.title,
description: response.description,
categories: response.categories,
clientSide: response.client_side,
serverSide: response.server_side,
downloadsCount: response.downloads,
licenseCode: response.license.id,
creationDate: new Date(response.published),
updateDate: new Date(response.updated)
}
},
async listVersions(idOrSlug: string, loader: Loader, minecraftVersion: string): Promise<ModrinthVersion[]> {
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`)
return response.map(transformApiModVersion)
},
async getVersion(id: string): Promise<ModrinthVersion | null> {
try {
const response = await getModrinthApiOptional(`v2/version/${id}`)
return transformApiModVersion(response)
} catch (e: unknown) {
if (e instanceof HTTPError && e.response.statusCode === 404) return null
throw e
}
}
}

115
src/output.ts Normal file
View file

@ -0,0 +1,115 @@
import ora, { Ora } from "ora"
import kleur from "kleur"
import { default as wrapAnsi } from "wrap-ansi"
import { last, without } from "lodash-es"
import figures from "figures"
let loadersStack: InternalLoader[] = []
export interface Loader {
setText(text: string): void
fail(message?: string): void
failAndExit(message?: string): never
stop(): void
}
export interface InternalLoader extends Loader {
spinner: Ora
isActive: boolean
state: "running" | "stopped" | "should_fail"
text: string
activate(): void
deactivate(): void
}
export const output = {
startLoading(text: string): Loader {
const loader: InternalLoader = {
isActive: false,
state: "running",
text,
spinner: ora({
spinner: "dots4",
color: "blue"
}),
fail(message?: string) {
if (this.state !== "running") throw new Error("state is not 'running'")
if (message !== undefined) this.text = this.text + " — " + kleur.red(message)
if (this.isActive) {
this.spinner.fail(this.text)
this.stop()
} else {
this.state = "should_fail"
}
},
failAndExit(message?: string): never {
this.fail(message)
process.exit(1)
},
setText(text: string) {
if (this.state !== "running") throw new Error("state is not 'running'")
this.text = text
},
stop() {
this.state = "stopped"
if (this.isActive) this.spinner.stop()
loadersStack = without(loadersStack, this)
last(loadersStack)?.activate()
},
activate() {
if (!this.isActive) {
this.isActive = true
if (this.state === "should_fail") {
this.spinner.fail(this.text)
this.stop()
} else if (this.state === "running") this.spinner.start(this.text)
}
},
deactivate() {
if (this.isActive) {
this.isActive = false
if (this.state === "running") this.spinner.stop()
}
}
}
last(loadersStack)?.deactivate()
loadersStack.push(loader)
loader.activate()
return loader
},
print(text: string) {
const loader = last(loadersStack)
if (loader === undefined) {
process.stdout.write(text)
} else {
loader.deactivate()
process.stdout.write(text + "\n" + "\n")
loader.activate()
}
},
println(text: string) {
this.print(text + "\n")
},
printlnWrapping(text: string) {
this.println(wrapAnsi(text, process.stdout.columns))
},
warn(text: string) {
this.printlnWrapping(`${kleur.yellow(figures.pointer)} ${text}`)
},
fail(text: string) {
last(loadersStack)?.fail()
this.printlnWrapping(`${kleur.red(figures.pointer)} ${text}`)
},
failAndExit(text: string): never {
this.fail(text)
process.exit(1)
}
}

149
src/pack.ts Normal file
View file

@ -0,0 +1,149 @@
import { findPackDirectoryPath, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
import { resolve } from "path"
import { output } from "./output.js"
import pLimit from "p-limit"
import kleur from "kleur"
import { getModFileDataForModrinthVersion, modrinthApi, ReleaseChannel, sortModrinthVersionsByPreference } from "./modrinth.js"
import semver from "semver"
export type Loader = "fabric" | "quilt"
export interface Update {
mod: Mod
activeVersion: string
availableVersion: string
apply(): Promise<void>
}
export interface Pack {
path: string
horizrFile: HorizrFile
mods: Mod[]
addMod(id: string, file: ModFile): Promise<void>
findModByCode(code: string): Mod | null
findModByCodeOrFail(code: string): Mod
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
resolvePath(...segments: string[]): string
}
export interface Mod {
id: string
modFile: ModFile
saveModFile(): Promise<void>
checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null>
}
let pack: Pack
export async function usePack(): Promise<Pack> {
if (pack === undefined) {
const path = await findPackDirectoryPath()
pack = {
path,
horizrFile: await readHorizrFile(path),
mods: await Promise.all((await readModIds(path)).map(async id => {
const mod: Mod = {
id,
modFile: (await readModFile(path, id))!,
async saveModFile() {
await writeModFile(path, id, this.modFile)
},
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
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.loader, 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,
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(path, id, file)
},
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
}
},
findModByCodeOrFail(code: string): Mod {
const mod = this.findModByCode(code)
if (mod === null) return output.failAndExit("The mod could not be found.")
return mod
},
async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]> {
const limit = pLimit(5)
const loader = output.startLoading(`Checking for updates (0/${this.mods.length})`)
let finishedCount = 0
const updates: Array<Update | null> = 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[]
},
resolvePath(...segments): string {
return resolve(path, ...segments)
}
}
}
return pack
}

29
src/utils.ts Normal file
View file

@ -0,0 +1,29 @@
import envPaths from "env-paths"
import { InvalidArgumentError } from "commander"
import hash, { HashaInput } from "hasha"
export const paths = envPaths("horizr", { suffix: "" })
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
}

15
test-pack/horizr.json Normal file
View file

@ -0,0 +1,15 @@
{
"formatVersion": 1,
"meta": {
"name": "Test",
"version": "1.0.0",
"authors": ["John Doe"],
"description": "A test pack for testing the horizr CLI. It is not intended for playing.",
"license": "MIT"
},
"loader": "fabric",
"versions": {
"loader": "0.14.7",
"minecraft": "1.18.2"
}
}

19
test-pack/mods/charm.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "Charm",
"enabled": true,
"ignoreUpdates": true,
"side": "client+server",
"file": {
"version": "4.2.0+1.18.2",
"name": "charm-fabric-1.18.2-4.2.0.jar",
"size": 3413876,
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
"hashAlgorithm": "sha512",
"hash": "3c8cd08ab1e37dcbf0f5a956cd20d84c98e58ab49fdc13faafb9c2af4dbf7fba7c8328cb5365997fe4414cfc5cb554ed13b3056a22df1c6bd335594f380facb6"
},
"source": {
"type": "modrinth",
"modId": "pOQTcQmj",
"versionId": "BT9G1Jjs"
}
}

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
"esModuleInterop": true,
"strict": true,
"rootDir": "src",
"outDir": "dist",
"sourceMap": false,
"declaration": false,
"skipLibCheck": true
},
"include": [
"src"
],
"exclude": [
"node_modules"
]
}