From 862048d1690ca0a807cd4fd59558f54d417ad1f5 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Tue, 16 Aug 2022 10:23:45 +0200 Subject: [PATCH] Implement Modrinth export --- package.json | 2 + pnpm-lock.yaml | 13 ++++ src/commands/modrinth.ts | 68 ++++++++++++++++--- src/commands/packwiz.ts | 22 +++---- src/files.ts | 57 ++++++++-------- src/main.ts | 4 +- src/{modrinth.ts => modrinth/api.ts} | 98 +++------------------------- src/modrinth/utils.ts | 87 ++++++++++++++++++++++++ src/pack.ts | 31 ++++----- src/path.ts | 62 ++++++++++++++++++ src/shared.ts | 3 + src/utils.ts | 5 +- 12 files changed, 291 insertions(+), 161 deletions(-) rename src/{modrinth.ts => modrinth/api.ts} (61%) create mode 100644 src/modrinth/utils.ts create mode 100644 src/path.ts create mode 100644 src/shared.ts diff --git a/package.json b/package.json index 1a9ed1d..9f35892 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c66d42e..e683024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/commands/modrinth.ts b/src/commands/modrinth.ts index e643cb9..970d913 100644 --- a/src/commands/modrinth.ts +++ b/src/commands/modrinth.ts @@ -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 ") 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) { diff --git a/src/commands/packwiz.ts b/src/commands/packwiz.ts index 546ee8d..60d3a24 100644 --- a/src/commands/packwiz.ts +++ b/src/commands/packwiz.ts @@ -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)}`} diff --git a/src/files.ts b/src/files.ts index 6507c89..f437f5f 100644 --- a/src/files.ts +++ b/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 { 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>( - packPath: string, - filePath: string, + packPath: Path, + filePath: Path, schema: S ): Promise | 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).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>(packPath: string, filePath: string, schema: S, data: z.input) { - const absolutePath = resolve(packPath, filePath) - await fs.mkdirp(dirname(absolutePath)) +export async function writeJsonFileInPack>(packPath: Path, filePath: Path, schema: S, data: z.input) { + 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 -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 @@ -107,22 +110,22 @@ const modFileSchema = z.object({ export type ModFile = z.output -export async function readModFile(packPath: string, modId: string): Promise { - return await readJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema) +export async function readModFile(packPath: Path, modId: string): Promise { + return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema) } -export async function writeModFile(packPath: string, modId: string, data: z.input): Promise { - await writeJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema, data) +export async function writeModFile(packPath: Path, modId: string, data: z.input): Promise { + await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data) } -export async function removeModFile(packPath: string, modId: string): Promise { - await fs.remove(resolve(packPath, `mods/${modId}.json`)) +export async function removeModFile(packPath: Path, modId: string): Promise { + 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)) } diff --git a/src/main.ts b/src/main.ts index 5a2dbc7..f37666c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 ") 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.")}`) }) diff --git a/src/modrinth.ts b/src/modrinth/api.ts similarity index 61% rename from src/modrinth.ts rename to src/modrinth/api.ts index 71d4877..f0d9b1b 100644 --- a/src/modrinth.ts +++ b/src/modrinth/api.ts @@ -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 { return response } -const dependencyToRelatedVersionType: Record["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 { + async listVersions(idOrSlug: string, loader: ModLoader, minecraftVersion: string): Promise { const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`) return response.map(transformApiModVersion) diff --git a/src/modrinth/utils.ts b/src/modrinth/utils.ts new file mode 100644 index 0000000..26d3afc --- /dev/null +++ b/src/modrinth/utils.ts @@ -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["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] +} diff --git a/src/pack.ts b/src/pack.ts index 76f56e9..1c2345d 100644 --- a/src/pack.ts +++ b/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 - findModByCode(code: string): Mod | null - findModByCodeOrFail(code: string): Mod checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise - - resolvePath(...segments: string[]): string } export interface Mod { @@ -44,17 +40,17 @@ let pack: Pack export async function usePack(): Promise { 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 { if (mod.modFile.ignoreUpdates) return null @@ -106,7 +102,7 @@ export async function usePack(): Promise { 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 { loader.stop() return updates.filter(info => info !== null) as Update[] - }, - resolvePath(...segments): string { - return resolve(path, ...segments) } } } diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..7009ef9 --- /dev/null +++ b/src/path.ts @@ -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) +} diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..a20c94d --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,3 @@ +export type ModLoader = "fabric" | "quilt" +export type ReleaseChannel = "alpha" | "beta" | "release" +export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"] diff --git a/src/utils.ts b/src/utils.ts index 0b02e1c..78a1436 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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))