Implement Modrinth export
This commit is contained in:
parent
81f60d9471
commit
862048d169
12 changed files with 291 additions and 161 deletions
|
@ -23,6 +23,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0",
|
||||
"cross-zip": "^4.0.0",
|
||||
"dedent": "^0.7.0",
|
||||
"env-paths": "^3.0.0",
|
||||
"figures": "^5.0.0",
|
||||
|
@ -43,6 +44,7 @@
|
|||
"zod": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cross-zip": "^4.0.0",
|
||||
"@types/dedent": "^0.7.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
|
|
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
|
@ -1,6 +1,7 @@
|
|||
lockfileVersion: 5.4
|
||||
|
||||
specifiers:
|
||||
'@types/cross-zip': ^4.0.0
|
||||
'@types/dedent': ^0.7.0
|
||||
'@types/fs-extra': ^9.0.13
|
||||
'@types/lodash-es': ^4.17.6
|
||||
|
@ -8,6 +9,7 @@ specifiers:
|
|||
'@types/semver': ^7.3.12
|
||||
'@types/wrap-ansi': ^8.0.1
|
||||
commander: ^9.4.0
|
||||
cross-zip: ^4.0.0
|
||||
dedent: ^0.7.0
|
||||
env-paths: ^3.0.0
|
||||
figures: ^5.0.0
|
||||
|
@ -32,6 +34,7 @@ specifiers:
|
|||
|
||||
dependencies:
|
||||
commander: 9.4.0
|
||||
cross-zip: 4.0.0
|
||||
dedent: 0.7.0
|
||||
env-paths: 3.0.0
|
||||
figures: 5.0.0
|
||||
|
@ -52,6 +55,7 @@ dependencies:
|
|||
zod: 3.18.0
|
||||
|
||||
devDependencies:
|
||||
'@types/cross-zip': 4.0.0
|
||||
'@types/dedent': 0.7.0
|
||||
'@types/fs-extra': 9.0.13
|
||||
'@types/lodash-es': 4.17.6
|
||||
|
@ -115,6 +119,10 @@ packages:
|
|||
'@types/responselike': 1.0.0
|
||||
dev: false
|
||||
|
||||
/@types/cross-zip/4.0.0:
|
||||
resolution: {integrity: sha512-jZRaaM3aib7qgVhCWeo76vVNJW+8+ujJzP/JHPPA4WUp5YBNMsqiyu5uYWg2eS05+jgSfb5NN8eqrAG72Ab2kA==}
|
||||
dev: true
|
||||
|
||||
/@types/dedent/0.7.0:
|
||||
resolution: {integrity: sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==}
|
||||
dev: true
|
||||
|
@ -263,6 +271,11 @@ packages:
|
|||
json-buffer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/cross-zip/4.0.0:
|
||||
resolution: {integrity: sha512-MEzGfZo0rqE10O/B+AEcCSJLZsrWuRUvmqJTqHNqBtALhaJc3E3ixLGLJNTRzEA2K34wbmOHC4fwYs9sVsdcCA==}
|
||||
engines: {node: '>=12.10'}
|
||||
dev: false
|
||||
|
||||
/currently-unhandled/0.4.1:
|
||||
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
|
@ -2,23 +2,21 @@ 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 { optionParsePositiveInteger, truncateWithEllipsis, zip } 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"
|
||||
} from "../modrinth/api.js"
|
||||
import dedent from "dedent"
|
||||
import ago from "s-ago"
|
||||
import semver from "semver"
|
||||
import { output } from "../output.js"
|
||||
import fs from "fs-extra"
|
||||
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js"
|
||||
|
||||
const modrinthCommand = new Command("modrinth")
|
||||
.alias("mr")
|
||||
|
@ -268,10 +266,60 @@ modrinthVersionCommand.command("activate <id>")
|
|||
await handleActivate(modrinthMod, modrinthVersion, options.force)
|
||||
})
|
||||
|
||||
modrinthVersionCommand.command("export")
|
||||
.description("Generate a Modrinth pack file suitable for uploading")
|
||||
.action(async () => {
|
||||
// TODO: Implement export
|
||||
modrinthCommand.command("export")
|
||||
.description("Generate a Modrinth pack file suitable for uploading.")
|
||||
.option("-z, --no-zip", "Skip the creation of a zipped .mrpack file.")
|
||||
.option("-c, --clear", "Remove the output directory afterwards.")
|
||||
.action(async options => {
|
||||
const pack = await usePack()
|
||||
const loader = output.startLoading("Generating")
|
||||
|
||||
const outputDirectory = pack.rootDirectoryPath.resolve("modrinth-pack")
|
||||
await fs.remove(outputDirectory.toString())
|
||||
await fs.mkdirp(outputDirectory.toString())
|
||||
|
||||
await fs.writeJson(outputDirectory.resolve("modrinth.index.json").toString(), {
|
||||
formatVersion: 1,
|
||||
game: "minecraft",
|
||||
versionId: pack.horizrFile.meta.version,
|
||||
name: pack.horizrFile.meta.name,
|
||||
summary: pack.horizrFile.meta.description,
|
||||
dependencies: {
|
||||
minecraft: pack.horizrFile.versions.minecraft,
|
||||
[`${pack.horizrFile.loader}-loader`]: pack.horizrFile.versions.loader
|
||||
},
|
||||
files: pack.mods.map(mod => ({
|
||||
path: `mods/${mod.modFile.file.name}`,
|
||||
hashes: {
|
||||
sha1: mod.modFile.file.hashes.sha1,
|
||||
sha512: mod.modFile.file.hashes.sha512
|
||||
},
|
||||
env: {
|
||||
client: mod.modFile.side === "client" || mod.modFile.side === "client+server" ? "required" : "unsupported",
|
||||
server: mod.modFile.side === "server" || mod.modFile.side === "client+server" ? "required" : "unsupported"
|
||||
},
|
||||
downloads: [
|
||||
mod.modFile.file.downloadUrl
|
||||
],
|
||||
fileSize: mod.modFile.file.size
|
||||
}))
|
||||
})
|
||||
|
||||
if (!options.clear) output.println(kleur.green(`Generated Modrinth pack in ${kleur.yellow("modrinth-pack")}`))
|
||||
|
||||
if (options.zip) {
|
||||
loader.setText(`Creating ${kleur.yellow(".mrpack")} file`)
|
||||
await zip(outputDirectory.toString(), pack.rootDirectoryPath.resolve("pack.mrpack").toString())
|
||||
output.println(kleur.green(`Created ${kleur.yellow("pack.mrpack")}`))
|
||||
}
|
||||
|
||||
if (options.clear) {
|
||||
loader.setText("Removing the output directory")
|
||||
await fs.remove(outputDirectory.toString())
|
||||
output.println(kleur.green(`Removed the ${kleur.yellow("modrinth-pack")} directory`))
|
||||
}
|
||||
|
||||
loader.stop()
|
||||
})
|
||||
|
||||
async function handleActivate(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, force: boolean) {
|
||||
|
|
|
@ -3,7 +3,6 @@ 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"
|
||||
|
||||
|
@ -25,9 +24,10 @@ packwizCommand.command("export")
|
|||
|
||||
const loader = output.startLoading("Generating")
|
||||
|
||||
const rootDirectoryPath = pack.resolvePath("packwiz")
|
||||
await fs.remove(rootDirectoryPath)
|
||||
await fs.mkdirp(pack.resolvePath("packwiz/mods"))
|
||||
const outputDirectoryPath = pack.rootDirectoryPath.resolve("packwiz")
|
||||
const modsDirectoryPath = outputDirectoryPath.resolve("mods")
|
||||
await fs.remove(outputDirectoryPath.toString())
|
||||
await fs.mkdirp(modsDirectoryPath.toString())
|
||||
|
||||
const indexedFiles: IndexedFile[] = []
|
||||
for (const mod of pack.mods) {
|
||||
|
@ -40,16 +40,16 @@ packwizCommand.command("export")
|
|||
side = "${mod.modFile.side.replace("client+server", "both")}"
|
||||
|
||||
[download]
|
||||
hash-format = "${mod.modFile.file.hashAlgorithm}"
|
||||
hash = ${JSON.stringify(mod.modFile.file.hash)}
|
||||
hash-format = "sha512"
|
||||
hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)}
|
||||
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
|
||||
`
|
||||
|
||||
const path = pack.resolvePath("packwiz/mods", mod.id + ".toml")
|
||||
await fs.writeFile(path, content)
|
||||
const path = modsDirectoryPath.resolve(mod.id + ".toml")
|
||||
await fs.writeFile(path.toString(), content)
|
||||
|
||||
indexedFiles.push({
|
||||
path: relative(rootDirectoryPath, path),
|
||||
path: outputDirectoryPath.relative(path).toString(),
|
||||
isMeta: true,
|
||||
sha512HashHex: await getSha512HexHash(content)
|
||||
})
|
||||
|
@ -68,10 +68,10 @@ packwizCommand.command("export")
|
|||
`).join("\n\n")}
|
||||
`
|
||||
|
||||
await fs.writeFile(pack.resolvePath("packwiz/index.toml"), index)
|
||||
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
||||
const indexHash = await getSha512HexHash(index)
|
||||
|
||||
await fs.writeFile(pack.resolvePath("packwiz/pack.toml"), dedent`
|
||||
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), 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)}`}
|
||||
|
|
57
src/files.ts
57
src/files.ts
|
@ -2,49 +2,50 @@ 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 { dirname } from "path"
|
||||
import { findUp } from "find-up"
|
||||
import { output } from "./output.js"
|
||||
import { Path } from "./path.js"
|
||||
|
||||
export async function findPackDirectoryPath() {
|
||||
export async function findPackDirectoryPath(): Promise<Path> {
|
||||
if (process.argv0.endsWith("/node")) { // run using pnpm
|
||||
return resolve(process.cwd(), "./test-pack")
|
||||
return Path.createAbsolute("./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)
|
||||
return Path.createAbsolute(dirname(parent))
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
||||
packPath: string,
|
||||
filePath: string,
|
||||
packPath: Path,
|
||||
filePath: Path,
|
||||
schema: S
|
||||
): Promise<z.output<S> | null> {
|
||||
let data
|
||||
|
||||
try {
|
||||
data = await fs.readJson(resolve(packPath, filePath))
|
||||
data = await fs.readJson(packPath.resolve(filePath).toString())
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath)} does not contain valid JSON.`)
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} 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 output.failAndExit(`${kleur.yellow(filePath.toString())} 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))
|
||||
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) {
|
||||
const absolutePath = packPath.resolve(filePath)
|
||||
await fs.mkdirp(absolutePath.getParent().toString())
|
||||
|
||||
await fs.writeJson(absolutePath, schema.parse(data), { spaces: 2 })
|
||||
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
|
||||
}
|
||||
|
||||
const horizrFileSchema = z.object({
|
||||
|
@ -65,8 +66,8 @@ const horizrFileSchema = z.object({
|
|||
|
||||
export type HorizrFile = z.output<typeof horizrFileSchema>
|
||||
|
||||
export async function readHorizrFile(packPath: string) {
|
||||
const data = await readJsonFileInPack(packPath, "horizr.json", horizrFileSchema)
|
||||
export async function readHorizrFile(packPath: Path) {
|
||||
const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema)
|
||||
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
||||
if (data.formatVersion !== 1) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
||||
|
||||
|
@ -86,8 +87,10 @@ const modFileDataSchema = z.object({
|
|||
name: z.string(),
|
||||
size: z.number().int().min(0).optional(),
|
||||
downloadUrl: z.string().url(),
|
||||
hashAlgorithm: z.enum(["sha1", "sha256", "sha512"]),
|
||||
hash: z.string()
|
||||
hashes: z.object({ // Adopted from Modrinth
|
||||
sha1: z.string(),
|
||||
sha512: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export type ModFileData = z.output<typeof modFileDataSchema>
|
||||
|
@ -107,22 +110,22 @@ const modFileSchema = z.object({
|
|||
|
||||
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 readModFile(packPath: Path, modId: string): Promise<ModFile | null> {
|
||||
return await readJsonFileInPack(packPath, Path.create("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 writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
||||
await writeJsonFileInPack(packPath, Path.create("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 removeModFile(packPath: Path, modId: string): Promise<void> {
|
||||
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
|
||||
}
|
||||
|
||||
export async function readModIds(packPath: string) {
|
||||
const modsPath = resolve(packPath, "./mods")
|
||||
await fs.mkdirp(modsPath)
|
||||
const files = await fs.readdir(modsPath, { withFileTypes: true })
|
||||
export async function readModIds(packPath: Path) {
|
||||
const modsPath = packPath.resolve("mods")
|
||||
await fs.mkdirp(modsPath.toString())
|
||||
const files = await fs.readdir(modsPath.toString(), { withFileTypes: true })
|
||||
|
||||
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ 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"
|
||||
import { releaseChannelOrder } from "./shared.js"
|
||||
|
||||
const program = new Command("horizr")
|
||||
|
||||
|
@ -39,7 +39,7 @@ program.command("remove <code>")
|
|||
const pack = await usePack()
|
||||
const mod = pack.findModByCodeOrFail(code)
|
||||
|
||||
await removeModFile(pack.path, mod.id)
|
||||
await removeModFile(pack.rootDirectoryPath, mod.id)
|
||||
|
||||
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
||||
})
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
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"
|
||||
import { delay } from "../utils.js"
|
||||
import { output } from "../output.js"
|
||||
import { paths } from "../path.js"
|
||||
import { dependencyToRelatedVersionType } from "./utils.js"
|
||||
import { ModLoader, ReleaseChannel } from "../shared.js"
|
||||
|
||||
const keyvCache = new KeyvFile({
|
||||
filename: resolve(paths.cache, "http.json"),
|
||||
filename: paths.cache.resolve("http.json").toString(),
|
||||
writeDelay: 50,
|
||||
expiredCheckDelay: 24 * 3600 * 1000,
|
||||
encode: JSON.stringify,
|
||||
|
@ -58,84 +54,6 @@ async function getModrinthApi(url: string): Promise<any> {
|
|||
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,
|
||||
|
@ -222,7 +140,7 @@ export interface ModrinthVersionFile {
|
|||
export const modrinthApi = {
|
||||
clearCache: () => keyvCache.clear(),
|
||||
async searchMods(
|
||||
loader: Loader,
|
||||
loader: ModLoader,
|
||||
minecraftVersion: string,
|
||||
query: string,
|
||||
pagination: PaginationOptions
|
||||
|
@ -266,7 +184,7 @@ export const modrinthApi = {
|
|||
updateDate: new Date(response.updated)
|
||||
}
|
||||
},
|
||||
async listVersions(idOrSlug: string, loader: Loader, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
||||
async listVersions(idOrSlug: string, loader: ModLoader, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`)
|
||||
|
||||
return response.map(transformApiModVersion)
|
87
src/modrinth/utils.ts
Normal file
87
src/modrinth/utils.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { IterableElement } from "type-fest"
|
||||
import { sortBy } from "lodash-es"
|
||||
import { Mod, Pack, usePack } from "../pack.js"
|
||||
import { ModFile, ModFileData, ModFileModrinthSource } from "../files.js"
|
||||
import { pathExists } from "fs-extra"
|
||||
import { nanoid } from "nanoid/non-secure"
|
||||
import { output } from "../output.js"
|
||||
import kleur from "kleur"
|
||||
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
||||
import { releaseChannelOrder } from "../shared.js"
|
||||
|
||||
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
||||
required: "hard_dependency",
|
||||
optional: "soft_dependency",
|
||||
embedded: "embedded_dependency",
|
||||
incompatible: "incompatible"
|
||||
}
|
||||
|
||||
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,
|
||||
hashes: {
|
||||
sha1: modrinthVersionFile.hashes.sha1,
|
||||
sha512: modrinthVersionFile.hashes.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.rootDirectoryPath.resolve("mods", `${id}.json`).toString())) {
|
||||
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]
|
||||
}
|
31
src/pack.ts
31
src/pack.ts
|
@ -1,12 +1,12 @@
|
|||
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 { modrinthApi } from "./modrinth/api.js"
|
||||
import semver from "semver"
|
||||
|
||||
export type Loader = "fabric" | "quilt"
|
||||
import { Path } from "./path.js"
|
||||
import { ReleaseChannel } from "./shared.js"
|
||||
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
|
||||
|
||||
export interface Update {
|
||||
mod: Mod
|
||||
|
@ -16,19 +16,15 @@ export interface Update {
|
|||
}
|
||||
|
||||
export interface Pack {
|
||||
path: string
|
||||
rootDirectoryPath: Path
|
||||
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 {
|
||||
|
@ -44,17 +40,17 @@ let pack: Pack
|
|||
|
||||
export async function usePack(): Promise<Pack> {
|
||||
if (pack === undefined) {
|
||||
const path = await findPackDirectoryPath()
|
||||
const rootDirectoryPath = await findPackDirectoryPath()
|
||||
|
||||
pack = {
|
||||
path,
|
||||
horizrFile: await readHorizrFile(path),
|
||||
mods: await Promise.all((await readModIds(path)).map(async id => {
|
||||
rootDirectoryPath,
|
||||
horizrFile: await readHorizrFile(rootDirectoryPath),
|
||||
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
|
||||
const mod: Mod = {
|
||||
id,
|
||||
modFile: (await readModFile(path, id))!,
|
||||
modFile: (await readModFile(rootDirectoryPath, id))!,
|
||||
async saveModFile() {
|
||||
await writeModFile(path, id, this.modFile)
|
||||
await writeModFile(rootDirectoryPath, id, this.modFile)
|
||||
},
|
||||
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
|
||||
if (mod.modFile.ignoreUpdates) return null
|
||||
|
@ -106,7 +102,7 @@ export async function usePack(): Promise<Pack> {
|
|||
return mod
|
||||
})),
|
||||
async addMod(id: string, file: ModFile) {
|
||||
await writeModFile(path, id, file)
|
||||
await writeModFile(rootDirectoryPath, id, file)
|
||||
},
|
||||
findModByCode(code: string): Mod | null {
|
||||
if (code.startsWith("mrv:")) {
|
||||
|
@ -138,9 +134,6 @@ export async function usePack(): Promise<Pack> {
|
|||
|
||||
loader.stop()
|
||||
return updates.filter(info => info !== null) as Update[]
|
||||
},
|
||||
resolvePath(...segments): string {
|
||||
return resolve(path, ...segments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
62
src/path.ts
Normal file
62
src/path.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import pathModule from "path"
|
||||
import envPaths from "env-paths"
|
||||
|
||||
export class Path {
|
||||
constructor(private readonly value: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path by resolving the last segment against the other segments, this path and the current working directory.
|
||||
*/
|
||||
resolve(...segments: (string | Path)[]) {
|
||||
if (this.isAbsolute()) return this
|
||||
else return new Path(pathModule.resolve(this.value, ...segments.map(s => s.toString())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new path with this path and the segments joined together.
|
||||
*/
|
||||
join(...segments: (string | Path)[]) {
|
||||
return new Path(pathModule.join(this.value, ...segments.map(s => s.toString())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the relative path from this path to the other path.
|
||||
*/
|
||||
relative(other: Path) {
|
||||
return new Path(pathModule.relative(this.value, other.value))
|
||||
}
|
||||
|
||||
getParent() {
|
||||
return new Path(pathModule.dirname(this.value))
|
||||
}
|
||||
|
||||
isAbsolute() {
|
||||
return pathModule.isAbsolute(this.value)
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.value
|
||||
}
|
||||
|
||||
static create(...segments: string[]) {
|
||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||
|
||||
return new Path(pathModule.join(...segments))
|
||||
}
|
||||
|
||||
static createAbsolute(...segments: string[]) {
|
||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||
|
||||
return new Path(pathModule.resolve(...segments))
|
||||
}
|
||||
}
|
||||
|
||||
const rawPaths = envPaths("horizr", { suffix: "" })
|
||||
export const paths = {
|
||||
cache: new Path(rawPaths.cache),
|
||||
config: new Path(rawPaths.config),
|
||||
data: new Path(rawPaths.data),
|
||||
log: new Path(rawPaths.log),
|
||||
temp: new Path(rawPaths.temp)
|
||||
}
|
3
src/shared.ts
Normal file
3
src/shared.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type ModLoader = "fabric" | "quilt"
|
||||
export type ReleaseChannel = "alpha" | "beta" | "release"
|
||||
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
|
@ -1,8 +1,9 @@
|
|||
import envPaths from "env-paths"
|
||||
import { InvalidArgumentError } from "commander"
|
||||
import hash, { HashaInput } from "hasha"
|
||||
import { zip as zipWithCallback } from "cross-zip"
|
||||
import { promisify } from "util"
|
||||
|
||||
export const paths = envPaths("horizr", { suffix: "" })
|
||||
export const zip = promisify(zipWithCallback)
|
||||
|
||||
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue