initial commit
This commit is contained in:
commit
fd56aa7aa0
16 changed files with 2385 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
.idea/
|
||||
dist/
|
19
LICENSE
Normal file
19
LICENSE
Normal 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
40
docs/horizr.md
Normal 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
56
package.json
Normal 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
1002
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
317
src/commands/modrinth.ts
Normal file
317
src/commands/modrinth.ts
Normal 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
94
src/commands/packwiz.ts
Normal 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
128
src/files.ts
Normal 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
95
src/main.ts
Normal 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
284
src/modrinth.ts
Normal 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
115
src/output.ts
Normal 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
149
src/pack.ts
Normal 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
29
src/utils.ts
Normal 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
15
test-pack/horizr.json
Normal 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
19
test-pack/mods/charm.json
Normal 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
20
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Loading…
Add table
Reference in a new issue