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