Compare commits

...

16 commits
v1.1.0 ... main

53 changed files with 1932 additions and 1790 deletions

7
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
.idea/
dist/
/node_modules/
/.idea/
/dist/
/schemas/

View file

@ -1,14 +1,12 @@
![horizr](./banner.png)
# horizr CLI
![npm](https://img.shields.io/npm/v/@horizr/cli?color=white&label=latest%20version&logoColor=red&style=flat-square)
> A CLI tool for creating and maintaining Minecraft modpacks using the Fabric loader.
🎉 Features:
- Access [Modrinth](https://modrinth.com/)
- Search
- Add
- View available versions
- View dependencies
- Add mods from [Modrinth](https://modrinth.com/)
- Check for updates and view changelogs before applying them
- Export the pack to the [Modrinth format (`.mrpack`)](https://docs.modrinth.com/docs/modpacks/format_definition/)
- Export the pack to the [`packwiz`](https://packwiz.infra.link/) format
@ -17,7 +15,7 @@
## Usage
Because both [pkg](https://github.com/vercel/pkg) and [nexe](https://github.com/nexe/nexe) dont support ES modules at the time of writing,
I cant publish executable files.
I cant publish executable files.
The only way of installing is therefore `npm`.
@ -27,70 +25,16 @@ $ npm i -g @horizr/cli
Run any command with the `-h` flag to see the available options.
A new pack can be initiated using `horizr init <path>`.
A new pack can be initialized using `horizr init <path>`.
## Examples
## Contributing
I developed this tool primarily for my own packs, thats why its missing some features I didnt absolutely need.
- Activate the latest (compatible) version of [Charm](https://modrinth.com/mod/charm)
```sh
$ horizr modrinth mod activate charm
Nevertheless, if you want a feature added, feel free to [create an issue](https://github.com/horizr/cli/issues/new).
A pull request would be even better.
# or short:
$ horizr mr mod a charm
```
- Activate `v4.1.1` of [Charm](https://modrinth.com/mod/charm)
```sh
$ horizr modrinth mod versions charm
# `BT9G1Jjs` is the version code you are looking for.
# This output will be colored in your console.
BT9G1Jjs 4.2.0+1.18.2 (↓ 137)
featured
Name: [1.18.2] 4.2.0
Channel: release
Minecraft versions: 1.18.2
Publication: last week
https://modrinth.com/mod/pOQTcQmj/version/BT9G1Jjs
# … more versions omitted for brevity
$ horizr modrinth version activate BT9G1Jjs
Charm (4.2.0+1.18.2) was successfully activated.
Dependencies
◉ Fabric API (P7dR8mSH): any version
```
- Check for updates
```sh
$ horizr update
# Because Sodium's version string is not a valid SemVer,
# the publication date will instead be used for comparison.
Sodium has no valid semantic version: mc1.18.2-0.4.1. The
publication date will instead be used.
Available updates
- charm Charm: 4.1.0+1.18.2 → 4.2.0+1.18.2
```
```sh
$ horizr update charm
Changelog for 4.2.0+1.18.2
* Added ebony wood.
* Fixed issue with Totems not always spawning or being
carried away by mobs.
# … omitted for brevity
Apply the update? [Y/n] y
Successfully updated Charm to 4.2.0+1.18.2.
```
Features I have in mind:
- List disabled source files
- Allow disabling static source files by adding `.disabled` to their name
- Import packwiz packs
- Hot-reloading `packwiz dev` command

BIN
banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@horizr/cli",
"version": "1.1.0",
"version": "2.0.1",
"main": "./dist/main.js",
"type": "module",
"license": "MIT",
@ -8,10 +8,11 @@
"repository": "https://github.com/horizr/cli",
"scripts": {
"start": "tsx src/main.ts",
"build": "del dist && tsc"
"build": "del dist && tsc",
"check": "tsc --noEmit"
},
"bin": {
"horizr": "bin/horizr.js"
"horizr": "./bin/horizr.js"
},
"files": [
"dist",
@ -19,11 +20,13 @@
],
"dependencies": {
"@root/walk": "^1.1.0",
"@sindresorhus/slugify": "^2.1.0",
"address": "^1.2.0",
"commander": "^9.4.0",
"dedent": "^0.7.0",
"enquirer": "^2.3.6",
"env-paths": "^3.0.0",
"fast-glob": "^3.2.11",
"figures": "^5.0.0",
"find-up": "^6.3.0",
"fs-extra": "^10.1.0",
@ -34,16 +37,18 @@
"lodash-es": "^4.17.21",
"loud-rejection": "^2.2.0",
"nanoid": "^4.0.0",
"open": "^8.4.0",
"ora": "^6.1.2",
"p-event": "^5.0.1",
"p-limit": "^4.0.0",
"s-ago": "^2.2.0",
"semver": "^7.3.7",
"serve-handler": "^6.1.3",
"toml": "^3.0.0",
"wrap-ansi": "^8.0.1",
"yazl": "^2.5.1",
"yesno": "^0.4.0",
"zod": "^3.18.0"
"zod": "^3.18.0",
"zod-to-json-schema": "^3.17.1"
},
"devDependencies": {
"@types/dedent": "^0.7.0",

817
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
import { Command } from "commander"
import fs from "fs-extra"
import { zodToJsonSchema } from "zod-to-json-schema"
import { metaFileContentSchema, packManifestFileSchema } from "../files.js"
export const exportJsonSchemasCommand = new Command("export-json-schemas")
.argument("<path>")
.description("Exports the pack manifest and meta-file JSON schemas.")
.action(async path => {
await fs.mkdirp(path)
await fs.writeJson(path + "/manifest.schema.json", {
title: "Horizr pack manifest",
$id: "https://horizr.moritzruth.de/schemas/pack/manifest.schema.json",
...zodToJsonSchema(packManifestFileSchema),
$schema: "https://json-schema.org/draft-07/schema" // HTTPS
}, { spaces: 2 })
await fs.writeJson(path + "/meta-file.schema.json", {
title: "Horizr pack meta-file",
$id: "https://horizr.moritzruth.de/schemas/pack/meta-file.schema.json",
...zodToJsonSchema(metaFileContentSchema),
$schema: "https://json-schema.org/draft-07/schema" // HTTPS
}, { spaces: 2 })
})

24
src/commands/info.ts Normal file
View file

@ -0,0 +1,24 @@
import { Command } from "commander"
import { output } from "../utils/output.js"
import dedent from "dedent"
import kleur from "kleur"
import { default as wrapAnsi } from "wrap-ansi"
import { usePack } from "../pack.js"
export const infoCommand = new Command("info")
.description("Print information about the pack.")
.action(async () => {
const pack = await usePack()
const { meta } = pack.manifest
output.println(dedent`
${kleur.bold(meta.name)} (${meta.version})
${meta.description === undefined ? "" : wrapAnsi(meta.description, process.stdout.columns) + "\n"}\
Authors: ${kleur.yellow(meta.authors.join(", "))}
License: ${kleur.yellow(meta.license.toUpperCase())}
Mods: ${kleur.yellow(pack.metaFiles.filter(metaFile => metaFile.isMod).length.toString())}
Minecraft version: ${kleur.yellow(pack.manifest.versions.minecraft)}
`)
})

85
src/commands/init.ts Normal file
View file

@ -0,0 +1,85 @@
import { Command } from "commander"
import { envPaths } from "../utils/path.js"
import fs from "fs-extra"
import { output } from "../utils/output.js"
import kleur from "kleur"
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "../fabricApi.js"
import enquirer from "enquirer"
import { PACK_MANIFEST_FILE_NAME, FORMAT_VERSION, PackManifest } from "../files.js"
import pathModule from "path"
import { EXPORTS_DIRECTORY_NAME } from "../pack.js"
import slugify from "@sindresorhus/slugify"
export const initCommand = new Command("init")
.argument("<path>")
.description("Initialize a new pack in the directory.")
.action(async pathString => {
const path = envPaths.cwd.resolveAny(pathString)
const manifestFilePath = path.resolve(PACK_MANIFEST_FILE_NAME)
if (await fs.pathExists(manifestFilePath.toString())) output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} already exists in the directory.`)
await fs.mkdirp(path.toString())
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
const answers: any = await enquirer.prompt([
{
name: "name",
type: "input",
message: "Name",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "authors",
type: "input",
message: "Authors (comma-separated)",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "description",
type: "text",
message: "Description"
},
{
name: "license",
type: "text",
message: "License (SPDX-ID)",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "minecraftVersion",
type: "autocomplete",
message: "Minecraft version",
choices: minecraftVersions.map(version => ({
name: version,
value: version
})),
// @ts-expect-error
limit: 10,
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
}
])
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
const file: PackManifest = {
formatVersion: FORMAT_VERSION,
slug: slugify(answers.name),
meta: {
name: answers.name,
version: "1.0.0",
description: answers.description === "" ? undefined : answers.description,
authors: (answers.authors as string).split(", ").map(a => a.trim()),
license: answers.license
},
versions: {
minecraft: answers.minecraftVersion,
fabric: fabricVersion
}
}
await fs.writeJson(manifestFilePath.toString(), file, { spaces: 2 })
await fs.writeFile(path.resolve(".gitignore").toString(), `/${EXPORTS_DIRECTORY_NAME}/`)
output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(pathModule.normalize(pathString))}`))
})

View file

@ -1,382 +0,0 @@
import { Command } from "commander"
import { take } from "lodash-es"
import { usePack } from "../pack.js"
import kleur from "kleur"
import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../utils.js"
import { default as wrapAnsi } from "wrap-ansi"
import figures from "figures"
import {
modrinthApi,
ModrinthMod,
ModrinthVersion,
ModrinthVersionRelation,
} 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")
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.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")
else loader.stop()
const existingMod = await findModForModrinthMod(modrinthMod)
const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions")
if (modrinthVersions.length === 0) {
const message =
`There are no versions compatible with the pack (Fabric ${kleur.yellow(pack.horizrFile.versions.fabric)}, 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")
else loader.stop()
const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions")
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.")
.option("-c, --changelog", "Show the changelog.")
.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))!
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 === "fabric" ? kleur.green(loader) : kleur.red(loader)).join(", ")}
Related mods: ${relationsColorKey}
${relationsList}
https://modrinth.com/mod/${modrinthMod.slug}/version/${modrinthVersion.versionString}
`)
if (options.changelog) {
output.println("")
output.println(kleur.underline("Changelog"))
if (modrinthVersion.changelog === null) output.println(kleur.gray("not available"))
else output.printlnWrapping(modrinthVersion.changelog)
}
})
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)
})
modrinthCommand.command("export")
.description("Export a Modrinth pack.")
.option("-s, --no-generate", "Skip regenerating the output directory.")
.option("-z, --no-zip", "Skip creating a zipped .mrpack file.")
.option("-c, --clear", "Remove the output directory afterwards.")
.action(async options => {
const pack = await usePack()
const outputDirectory = pack.paths.generated.resolve("modrinth-pack")
if (options.generate) {
const loader = output.startLoading("Generating")
await pack.validateOverridesDirectories()
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,
"fabric-loader": pack.horizrFile.versions.fabric
},
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
}))
}, { spaces: 2 })
if (await fs.pathExists(pack.paths.overrides["client-server"].toString())) await output.withLoading(
fs.copy(pack.paths.overrides["client-server"].toString(), outputDirectory.resolve("overrides").toString(), { recursive: true }),
"Copying client-server overrides"
)
if (await fs.pathExists(pack.paths.overrides["client"].toString())) await output.withLoading(
fs.copy(pack.paths.overrides["client"].toString(), outputDirectory.resolve("client-overrides").toString(), { recursive: true }),
"Copying client overrides"
)
if (await fs.pathExists(pack.paths.overrides["server"].toString())) await output.withLoading(
fs.copy(pack.paths.overrides["server"].toString(), outputDirectory.resolve("server-overrides").toString(), { recursive: true }),
"Copying server overrides"
)
output.println(kleur.green(`Generated Modrinth pack`))
loader.stop()
}
if (options.zip) {
if (!(await fs.pathExists(outputDirectory.toString())))
output.failAndExit(`The ${kleur.yellow("modrinth-pack")} directory does not exist.\nRun the command without ${kleur.yellow("--no-generate")} to create it.`)
await output.withLoading(zipDirectory(outputDirectory, pack.paths.generated.resolve("pack.mrpack")), `Creating ${kleur.yellow(".mrpack")} file`)
output.println(kleur.green(`Created ${kleur.yellow("pack.mrpack")}`))
}
if (options.clear) {
await fs.remove(outputDirectory.toString())
output.println(kleur.green(`Removed the ${kleur.yellow("modrinth-pack")} directory`))
}
})
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 }

View file

@ -0,0 +1,101 @@
import { Command } from "commander"
import { sideOption } from "../../utils/options.js"
import {
findMetaFileForModrinthMod,
getMetaFileContentVersionForModrinth,
getSideOfModrinthMod,
isModrinthVersionCompatible, resolveFullRelation,
resolveModrinthCode,
sortModrinthVersionsByPreference
} from "../../modrinth/index.js"
import { output } from "../../utils/output.js"
import { modrinthApi } from "../../modrinth/api.js"
import { Side, usePack } from "../../pack.js"
import kleur from "kleur"
import { FORMAT_VERSION, META_FILE_EXTENSION, metaFileContentSchema, writeJsonFile } from "../../files.js"
import fs from "fs-extra"
import enquirer from "enquirer"
import { orEmptyString } from "../../utils/strings.js"
export const activateCommand = new Command("activate")
.argument("<code>")
.alias("a")
.option("-s, --side <side>", "The side of the mod", sideOption, null)
.option("-y, --yes", "Skip confirmations.")
.action(async (code, options) => {
const pack = await usePack()
const resolvedCode = await output.withLoading(resolveModrinthCode(code), "Resolving code")
const modrinthMod = resolvedCode.modrinthMod
let modrinthVersion = resolvedCode.modrinthVersion
const existingMetaFile = findMetaFileForModrinthMod(pack.metaFiles, modrinthMod.id)
if (existingMetaFile !== null) {
output.println(`The mod is already active: ${kleur.yellow(existingMetaFile.relativePath.toString())} ${kleur.blue(existingMetaFile.content.version.name)}`)
const confirmed = options.yes || (await enquirer.prompt({
type: "confirm",
name: "confirmed",
message: "Do you want to continue?",
initial: false
}) as any).confirmed
if (!confirmed) process.exit()
}
let side: Side
const specifiedSide = getSideOfModrinthMod(modrinthMod)
const sideOverride = options.side
if (sideOverride === null) side = specifiedSide
else {
if (specifiedSide !== "universal" && specifiedSide !== sideOverride) return output.failAndExit(`Mod is incompatible with specified side: ${kleur.yellow(sideOverride)}`)
else side = sideOverride
}
if (modrinthVersion === null) {
const versions = await output.withLoading(modrinthApi.listVersions(modrinthMod.id, pack.manifest.versions.minecraft), "Fetching versions")
if (versions.length === 0) return output.failAndExit("No compatible version available.")
const sortedVersions = sortModrinthVersionsByPreference(versions)
modrinthVersion = sortedVersions[0]
} else {
if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.")
}
const absolutePath = pack.paths.source.resolve(side, "mods", `${modrinthMod.slug}.${META_FILE_EXTENSION}`)
const relativePath = pack.paths.source.relativeTo(absolutePath)
await fs.mkdirp(absolutePath.parent().toString())
await writeJsonFile(absolutePath, metaFileContentSchema, {
displayName: modrinthMod.title,
formatVersion: FORMAT_VERSION,
enabled: true,
version: getMetaFileContentVersionForModrinth(modrinthVersion),
source: {
type: "modrinth",
versionId: modrinthVersion.id,
modId: modrinthVersion.projectId,
ignoreUpdates: false
}
})
await pack.registerCreatedSourceFile(relativePath)
output.println(kleur.green(`Successfully wrote ${kleur.yellow(relativePath.toString())}`))
const loader = output.startLoading("Checking dependencies")
for (const relation of modrinthVersion.relations) {
if (relation.type === "hard_dependency") {
const { modrinthMod, modrinthVersion } = await resolveFullRelation(relation)
const metaFile = await findMetaFileForModrinthMod(pack.metaFiles, modrinthMod.id)
if (metaFile === null) {
const versionString = orEmptyString(modrinthVersion, v => ` ${kleur.blue(v.versionString)}`)
const idString = kleur.gray(modrinthMod.slug + orEmptyString(modrinthVersion, v => `@${v.versionString}`))
output.warn(`Unmet dependency: ${kleur.yellow(modrinthMod.title)}${versionString} ${idString}`)
}
}
}
loader.stop()
})

View file

@ -0,0 +1,41 @@
import { Command } from "commander"
import { usePack } from "../../pack.js"
import { output } from "../../utils/output.js"
import fs from "fs-extra"
import { generateOutputDirectory } from "../../modrinth/exporting.js"
import kleur from "kleur"
import { zipDirectory } from "../../utils/zip.js"
const EXPORT_OUTPUT_DIRECTORY_NAME = "modrinth"
export const exportCommand = new Command("export")
.option("-s, --no-generate", "Skip regenerating the output directory.")
.option("-z, --no-zip", "Skip creating a zipped .mrpack file.")
.option("-c, --clean", "Remove the output directory afterwards.")
.action(async options => {
const pack = await usePack()
const outputDirectoryPath = pack.paths.exports.resolve(EXPORT_OUTPUT_DIRECTORY_NAME)
const loader = output.startLoading("Exporting")
if (options.generate) {
await output.withLoading(generateOutputDirectory(outputDirectoryPath), "Generating the output directory")
output.println(kleur.green(`Generated Modrinth pack directory.`))
}
if (options.zip) {
const fileName = `${pack.manifest.slug}-${pack.manifest.meta.version}.mrpack`
if (!(await fs.pathExists(outputDirectoryPath.toString())))
output.failAndExit(`The ${kleur.yellow(EXPORT_OUTPUT_DIRECTORY_NAME)} export directory does not exist.\nRun the command without ${kleur.yellow("--no-generate")} to create it.`)
await output.withLoading(zipDirectory(outputDirectoryPath, pack.paths.exports.resolve(fileName)), `Creating ${kleur.yellow(".mrpack")} file`)
output.println(kleur.green(`Created ${kleur.yellow(fileName)}`))
}
if (options.clean) {
await fs.remove(outputDirectoryPath.toString())
output.println(kleur.green(`Removed the ${kleur.yellow(EXPORT_OUTPUT_DIRECTORY_NAME)} directory.`))
}
loader.stop()
})

View file

@ -0,0 +1,20 @@
import { Command } from "commander"
import { activateCommand } from "./activate.js"
import dedent from "dedent"
import kleur from "kleur"
import { openCommand } from "./open.js"
import { exportCommand } from "./export.js"
export const modrinthCommand = new Command("modrinth")
.alias("mr")
.addCommand(activateCommand)
.addCommand(exportCommand)
.addCommand(openCommand)
.addHelpText("afterAll", dedent`
\n${kleur.yellow("<code>")} may be one of the following:
- URL or slug of a Modrinth mod (${kleur.yellow("https://modrinth.com/mod/sodium")} or ${kleur.yellow("sodium")})
- URL of a Modrinth mod version (${kleur.yellow("https://modrinth.com/mod/sodium/version/mc1.19-0.4.2")})
- slug of a Modrinth mod and a version with a ${kleur.yellow("@")} in between (${kleur.yellow("sodium@mc1.19-0.4.2")})
- Modrinth project ID (${kleur.yellow("AANobbMI")} for Sodium)
- Modrinth version ID, prefixed with ${kleur.yellow("@")} (${kleur.yellow("@Yp8wLY1P")} for Sodium mc1.19-0.4.2)
`)

View file

@ -0,0 +1,24 @@
import { Command } from "commander"
import { usePack } from "../../pack.js"
import { output } from "../../utils/output.js"
import kleur from "kleur"
import open from "open"
export const openCommand = new Command("open")
.argument("<path>")
.action(async pathString => {
const pack = await usePack()
const metaFile = pack.getMetaFileFromInput(pathString)
if (metaFile.content.source?.type === "modrinth") {
const { modId } = metaFile.content.source
const url = `https://modrinth.com/mod/${encodeURIComponent(modId)}`
try {
await open(url, { wait: false })
output.printlnWrapping(kleur.green(`Opened ${kleur.yellow(url)} in your default browser.`))
} catch (e: unknown) {
output.fail(`Could not open ${kleur.yellow(url)} in a browser.`)
}
} else output.failAndExit(`${kleur.yellow(metaFile.relativePath.toString())} is not a Modrinth mod.`)
})

View file

@ -1,201 +0,0 @@
import { Command } from "commander"
import { Mod, usePack } from "../pack.js"
import fs from "fs-extra"
import dedent from "dedent"
import kleur from "kleur"
import { getLANAddress, getSha512HexHash, httpServeDirectory, optionParsePositiveInteger } from "../utils.js"
import { output } from "../output.js"
import { Visitor, walk } from "@root/walk"
import { Path } from "../path.js"
const packwizCommand = new Command("packwiz")
.alias("pw")
packwizCommand.command("import")
.description("Import a packwiz pack.")
.action(async () => {
output.failAndExit("Not implemented.")
// TODO: Import packwiz pack
})
packwizCommand.command("serve")
.description("Start an HTTP server in the packwiz directory.")
.option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
.option("-e, --expose", "Expose the HTTP server on all interfaces.")
.action(async options => {
const pack = await usePack()
const directoryPath = pack.paths.generated.resolve("packwiz")
if (!(await fs.pathExists(directoryPath.toString())))
output.failAndExit(`The ${kleur.yellow("packwiz")} directory does not exist. Generate it by running ${kleur.yellow("horizr packwiz export")}.`)
await serveExportOutput(directoryPath, options.port, options.expose)
})
// packwizCommand.command("dev")
// .description("serve + export with hot-reloading.")
// .option("-s, --server", "Use server overrides instead of client overrides.")
// .option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
// .option("-e, --expose", "Expose the HTTP server on all interfaces.")
// .action(async options => {
//
// })
packwizCommand.command("export")
.description("Generate a packwiz pack in the packwiz directory.")
.option("-s, --server", "Use server overrides instead of client overrides.")
.action(async options => {
await runExport(options.server)
})
async function runExport(forServer: boolean) {
const pack = await usePack()
const loader = output.startLoading("Generating")
const outputDirectoryPath = pack.paths.generated.resolve("packwiz")
await fs.remove(outputDirectoryPath.toString())
await fs.mkdirp(outputDirectoryPath.resolve("mods").toString())
const indexedFiles: IndexedFile[] = []
await indexMods(indexedFiles, outputDirectoryPath)
loader.setText(`Copying and hashing ${forServer ? "server" : "client"} overrides`)
await copyOverrides(indexedFiles, outputDirectoryPath, forServer)
loader.setText(`Writing ${kleur.yellow("index.toml")}`)
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
loader.stop()
output.println(kleur.green("Generated packwiz pack"))
return {
indexedFiles
}
}
interface IndexedFile {
path: string
sha512HashHex: string
isMeta: boolean
}
async function writeAndIndexModMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: Path, mod: Mod) {
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 = "sha512"
hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)}
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
`
const path = outputDirectoryPath.resolve(`mods/${mod.id}.toml`)
await fs.writeFile(path.toString(), content)
indexedFiles.push({
path: `mods/${mod.id}.toml`,
isMeta: true,
sha512HashHex: await getSha512HexHash(content)
})
}
async function indexMods(indexedFiles: IndexedFile[], outputDirectoryPath: Path, warn: boolean = true) {
const pack = await usePack()
for (const mod of pack.mods) {
if (warn && !mod.modFile.enabled) output.warn(`${kleur.yellow(mod.modFile.name)} is disabled and will not be included.`)
await output.withLoading(
writeAndIndexModMetaFile(indexedFiles, outputDirectoryPath, mod),
`Generating ${kleur.yellow(mod.id + ".toml")} (${indexedFiles.length + 1}/${pack.mods.length})`
)
}
}
async function copyOverrides(indexedFiles: IndexedFile[], outputDirectoryPath:Path, forServer: boolean) {
const pack = await usePack()
const createVisitor = (overridesDirectoryPath: Path): Visitor => async (error, path, dirent) => {
const relativePath = overridesDirectoryPath.relative(path)
if (error) output.warn(`${kleur.yellow(relativePath.toString())}: ${error.message}`)
else {
if (dirent.name.startsWith(".")) return false
if (dirent.isFile()) {
const outputPath = outputDirectoryPath.resolve(relativePath)
await fs.mkdirp(outputPath.getParent().toString())
await fs.copy(path, outputPath.toString())
indexedFiles.push({
path: relativePath.toString(),
isMeta: false,
sha512HashHex: await getSha512HexHash(await fs.readFile(overridesDirectoryPath.resolve(path).toString()))
})
}
}
}
const specificOverridesDirectoryPath = pack.paths.overrides[forServer ? "server" : "client"]
const universalOverridesDirectoryPath = pack.paths.overrides["client-server"]
if (await fs.pathExists(specificOverridesDirectoryPath.toString())) await walk(specificOverridesDirectoryPath.toString(), createVisitor(specificOverridesDirectoryPath))
if (await fs.pathExists(universalOverridesDirectoryPath.toString())) await walk(universalOverridesDirectoryPath.toString(), createVisitor(universalOverridesDirectoryPath))
}
async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: Path) {
const pack = await usePack()
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(outputDirectoryPath.resolve("index.toml").toString(), index)
const indexHash = await getSha512HexHash(index)
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)}`}
pack-format = "packwiz:1.0.0"
[versions]
minecraft = ${JSON.stringify(pack.horizrFile.versions.minecraft)}
fabric = ${JSON.stringify(pack.horizrFile.versions.fabric)}
[index]
file = "index.toml"
hash-format = "sha512"
hash = "${indexHash}"
`)
}
async function serveExportOutput(path: Path, port: number, expose: boolean) {
const lanAddress = await getLANAddress()
const localAddress = `http://localhost:${port}/pack.toml`
await new Promise<void>(resolve => {
httpServeDirectory(path, port, expose, () => {
if (expose) {
output.println(dedent`
${kleur.green("Serving at")}
Local: ${kleur.yellow(localAddress)}
Network: ${kleur.yellow(`http://${lanAddress}:${port}/pack.toml`)}
`)
} else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`)
resolve()
})
})
}
export { packwizCommand}

View file

@ -0,0 +1,48 @@
import { Command } from "commander"
import kleur from "kleur"
import { Side, usePack } from "../../pack.js"
import { output } from "../../utils/output.js"
import fs from "fs-extra"
import { IndexedFile, PACKWIZ_EXPORT_DIRECTORY_NAME, writeAndIndexMetaFile, writeAndIndexStaticSourceFile, writeIndexAndPackManifest } from "../../packwiz/exporting.js"
export const exportCommand = new Command("export")
.description("Export a packwiz pack.")
.option("-s, --server", "Use server overrides instead of client overrides. Only applies to static files.")
.action(async (path, command) => {
const pack = await usePack()
const side: Side = command.opts().server ? "server" : "client"
const loader = output.startLoading("Exporting")
const outputDirectoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME)
await fs.remove(outputDirectoryPath.toString())
const indexedFiles: IndexedFile[] = []
let i = 0
for (const metaFile of pack.metaFiles) {
i++
if (!metaFile.content.enabled) continue
await output.withLoading(
writeAndIndexMetaFile(indexedFiles, outputDirectoryPath, metaFile),
`Exporting ${kleur.yellow(metaFile.getDisplayString())} (${i}/${pack.metaFiles.length})`
)
}
i = 0
for (const staticSourceFile of pack.staticSourceFiles) {
i++
if (staticSourceFile.side !== "universal" && staticSourceFile.side !== side) continue
await output.withLoading(
writeAndIndexStaticSourceFile(indexedFiles, outputDirectoryPath, staticSourceFile),
`Exporting ${kleur.yellow(staticSourceFile.relativePath.toString())} (${i}/${pack.metaFiles.length})`
)
}
loader.setText(`Creating ${kleur.yellow("index.toml")} and ${kleur.yellow("pack.toml")}`)
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
loader.stop()
output.println(kleur.green("Generated packwiz pack"))
})

View file

@ -0,0 +1,22 @@
import { Command } from "commander"
import kleur from "kleur"
import { envPaths } from "../../utils/path.js"
import fs from "fs-extra"
import { output } from "../../utils/output.js"
import * as toml from "toml"
export const importCommand = new Command("import")
.argument("<path>")
.description("Import a packwiz pack.")
.addHelpText("after", kleur.red("Please create a backup of the pack before using this command."))
.action(async path => {
const inputDirectoryPath = envPaths.cwd.resolveAny(path)
const packTomlPath = inputDirectoryPath.resolve("pack.toml")
if (!await fs.pathExists(packTomlPath.toString()))
output.failAndExit(`${kleur.yellow(packTomlPath.toString())} does not exist.`)
const packTomlContent = toml.parse(await fs.readFile(packTomlPath.toString(), "utf-8"))
// TODO
})

View file

@ -0,0 +1,10 @@
import { Command } from "commander"
// import { importCommand } from "./import.js"
import { serveCommand } from "./serve.js"
import { exportCommand } from "./export.js"
export const packwizCommand = new Command("packwiz")
.alias("pw")
.addCommand(exportCommand)
// .addCommand(importCommand)
.addCommand(serveCommand)

View file

@ -0,0 +1,24 @@
import { Command } from "commander"
import kleur from "kleur"
import fs from "fs-extra"
import { output } from "../../utils/output.js"
import { positiveIntegerOption } from "../../utils/options.js"
import { usePack } from "../../pack.js"
import { PACKWIZ_EXPORT_DIRECTORY_NAME } from "../../packwiz/exporting.js"
import { httpServeDirectoryWithMessage } from "../../utils/http.js"
export const serveCommand = new Command("serve")
.description("Start an HTTP server in the packwiz directory.")
.option("-p, --port <port>", "The port of the HTTP server.", positiveIntegerOption, 8000)
.option("-e, --expose", "Expose the HTTP server on all interfaces.")
.action(async options => {
const pack = await usePack()
const directoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME)
if (!(await fs.pathExists(directoryPath.toString())))
output.failAndExit(`The ${kleur.yellow(pack.paths.root.relativeTo(directoryPath).toString())} directory does not exist. ` +
`Generate it by running ${kleur.yellow("horizr packwiz export")}.`
)
await httpServeDirectoryWithMessage(directoryPath, options.port, options.expose)
})

82
src/commands/update.ts Normal file
View file

@ -0,0 +1,82 @@
import { Command } from "commander"
import { output } from "../utils/output.js"
import kleur from "kleur"
import dedent from "dedent"
import figures from "figures"
import { ReleaseChannel, Update, usePack } from "../pack.js"
import { filterNulls, mapNotNull } from "../utils/collections.js"
import pLimit from "p-limit"
import { gtzIntegerOption } from "../utils/options.js"
import enquirer from "enquirer"
export const updateCommand = new Command("update")
.argument("[path]")
.description("Check for updates of all meta files or apply a specific update.")
.option("-y, --yes", "Skip confirmations")
.option("-a, --alpha", "Allow alpha versions")
.option("-b, --beta", "Allow beta versions")
.option("-c, --concurrency", "Number of concurrent checks", gtzIntegerOption, 5)
.action(async (pathString, options) => {
const pack = await usePack()
const allowedReleaseChannels: ReleaseChannel[] = ["release"]
if (options.alpha) allowedReleaseChannels.push("alpha")
if (options.beta) allowedReleaseChannels.push("beta")
if (pathString === undefined) {
const limit = pLimit(options.concurrency)
const updateFetches = mapNotNull(pack.metaFiles, metaFile => {
const { fetchUpdates } = metaFile
if (fetchUpdates === null) return null
else return limit(async () => {
const updates = await fetchUpdates(allowedReleaseChannels)
if (updates.length === 0) return null
else return updates[0]
})
})
const updates = filterNulls(
await output.withLoading(
Promise.all(updateFetches),
`Fetching updates for ${kleur.yellow(updateFetches.length)} meta files`
)
)
if (updates.length === 0) output.println(kleur.green("Everything up-to-date."))
else {
const getChange = (update: Update) => `${kleur.red(update.of.content.version.name)} ${figures.arrowRight} ${kleur.green(update.versionString)}`
output.println(dedent`
${kleur.underline("Available updates")}
${updates.map(update => `- ${update.of.getDisplayString()} ${getChange(update)}`).join("\n")}
`)
}
} else {
const metaFile = pack.getMetaFileFromInput(pathString)
if (metaFile.fetchUpdates === null) return output.failAndExit(`${kleur.yellow(metaFile.relativePath.toString())} is not updatable.`)
const updates = await metaFile.fetchUpdates(allowedReleaseChannels)
if (updates.length === 0) output.println(kleur.green("No updates available."))
else {
output.println(kleur.bold("Changelogs") + "\n")
for (let update of updates) {
output.println(kleur.underline(update.versionString))
output.printlnWrapping((update.changelog ?? kleur.gray("not provided")) + "\n")
}
const confirmed = options.yes || (await enquirer.prompt({
type: "confirm",
name: "confirmed",
message: "Apply the update?"
}) as any).confirmed
const update = updates[0]
if (confirmed) {
await output.withLoading(update.apply(), "Updating")
output.println(kleur.green(`Successfully updated ${metaFile.getDisplayString()} to ${kleur.yellow(update.versionString)}.`))
}
}
}
})

View file

@ -1,4 +1,4 @@
import { got } from "./utils.js"
import { got } from "./utils/http.js"
export async function fetchFabricMinecraftVersions(): Promise<string[]> {
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()

View file

@ -1,57 +1,55 @@
import { SafeParseError, z, ZodRawShape } from "zod"
import kleur from "kleur"
import fs from "fs-extra"
import * as process from "process"
import { dirname } from "path"
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
import process from "process"
import { findUp } from "find-up"
import { output } from "./output.js"
import { Path } from "./path.js"
import { Dirent } from "fs"
import { sides } from "./shared.js"
import { output } from "./utils/output.js"
import kleur from "kleur"
import { SafeParseError, z, ZodRawShape } from "zod"
import fs from "fs-extra"
import { dirname } from "path"
import fastGlob from "fast-glob"
import { sides } from "./pack.js"
export async function findPackDirectoryPath(): Promise<Path> {
export async function findPackDirectoryPath(): Promise<AbsolutePath> {
if (process.argv0.endsWith("/node")) { // run using pnpm
return Path.createAbsolute("./test-pack")
return envPaths.cwd.resolve("./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.`)
const parent = await findUp(PACK_MANIFEST_FILE_NAME)
if (parent === undefined) return output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} could not be found in the current working directory or any parent.`)
return Path.createAbsolute(dirname(parent))
return AbsolutePath.create(dirname(parent))
}
}
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
packPath: Path,
filePath: Path,
schema: S
): Promise<z.output<S> | null> {
export async function writeJsonFile<S extends z.ZodObject<ZodRawShape>>(path: AbsolutePath, schema: S, data: z.input<S>) {
await fs.mkdirp(path.parent().toString())
await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 })
}
export async function readJsonFile<S extends z.ZodObject<ZodRawShape>>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise<z.output<S> | null> {
let data
try {
data = await fs.readJson(packPath.resolve(filePath).toString())
data = await fs.readJson(rootPath.resolve(specificPath).toString())
} catch (e: unknown) {
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`)
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(specificPath.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.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))}${kleur.red(issue.message)}`).join("\n")}`)
return output.failAndExit(`${kleur.yellow(specificPath.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: Path, filePath: Path, schema: S, data: z.input<S>) {
const absolutePath = packPath.resolve(filePath)
await fs.mkdirp(absolutePath.getParent().toString())
export const FORMAT_VERSION = 1
export const PACK_MANIFEST_FILE_NAME = "horizr.json"
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
}
const horizrFileSchema = z.object({
formatVersion: z.string().or(z.number()),
export const packManifestFileSchema = z.object({
formatVersion: z.literal(FORMAT_VERSION),
slug: z.string(),
meta: z.object({
name: z.string(),
version: z.string(),
@ -65,75 +63,49 @@ const horizrFileSchema = z.object({
})
})
export type HorizrFile = z.output<typeof horizrFileSchema>
export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1
export type PackManifest = z.output<typeof packManifestFileSchema>
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 !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
export const META_FILE_EXTENSION = "hm.json"
return data
}
const modFileModrinthSourceSchema = z.object({
const metaFileModrinthSourceSchema = z.object({
type: z.literal("modrinth"),
modId: z.string(),
versionId: z.string()
})
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema>
export type MetaFileModrinthSource = z.output<typeof metaFileModrinthSourceSchema>
const modFileDataSchema = z.object({
version: z.string(),
const metaFileContentVersionSchema = z.object({
name: z.string(),
size: z.number().int().min(0).optional(),
fileName: z.string(),
downloadUrl: z.string().url(),
hashes: z.object({ // Adopted from Modrinth
hashes: z.object({
sha1: z.string(),
sha512: z.string()
})
})
export type ModFileData = z.output<typeof modFileDataSchema>
export type MetaFileContentVersion = z.output<typeof metaFileContentVersionSchema>
const modFileSchema = z.object({
name: z.string(),
export const metaFileContentSchema = z.object({
formatVersion: z.literal(FORMAT_VERSION),
displayName: z.string().optional(),
enabled: z.boolean().default(true),
ignoreUpdates: z.boolean().default(false),
side: z.enum(sides),
comment: z.string().optional(),
file: modFileDataSchema,
version: metaFileContentVersionSchema,
source: z.discriminatedUnion("type", [
modFileModrinthSourceSchema,
metaFileModrinthSourceSchema,
z.object({ type: z.literal("raw") })
])
]).and(z.object({
ignoreUpdates: z.boolean().default(false)
})).optional()
})
export type ModFile = z.output<typeof modFileSchema>
export type MetaFileContent = z.output<typeof metaFileContentSchema>
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: 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: Path, modId: string): Promise<void> {
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
}
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))
}
export async function getOverrideDirents(overridesDirectoryPath: Path): Promise<Dirent[]> {
if (!await fs.pathExists(overridesDirectoryPath.toString())) return []
return await fs.readdir(overridesDirectoryPath.toString(), { withFileTypes: true })
}
export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), {
cwd: sourceDirectoryPath.toString(),
followSymbolicLinks: false,
onlyFiles: true
}).then(paths => paths.map(path => RelativePath.create(path)))

View file

@ -1,198 +1,36 @@
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 { CURRENT_HORIZR_FILE_FORMAT_VERSION, HorizrFile, removeModFile } from "./files.js"
import { output } from "./output.js"
import figures from "figures"
import yesno from "yesno"
import { releaseChannelOrder } from "./shared.js"
import fs from "fs-extra"
import { Path } from "./path.js"
import enquirer from "enquirer"
import { clearCache } from "./utils.js"
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js"
import { AbsolutePath } from "./utils/path.js"
import { output } from "./utils/output.js"
import kleur from "kleur"
import loudRejection from "loud-rejection"
import { clearGotCache } from "./utils/http.js"
import { initCommand } from "./commands/init.js"
import { infoCommand } from "./commands/info.js"
import { updateCommand } from "./commands/update.js"
import { packwizCommand } from "./commands/packwiz/index.js"
import { modrinthCommand } from "./commands/modrinth/index.js"
import { exportJsonSchemasCommand } from "./commands/exportJsonSchemas.js"
const program = new Command("horizr")
.version(
(await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version,
(await fs.readJson(AbsolutePath.create(import.meta.url.slice(5)).parent().resolve("../package.json").toString())).version,
"-v, --version"
)
.option("--clear-cache", "Clear the HTTP cache before doing the operation.")
.on("option:clear-cache", () => {
clearCache()
clearGotCache()
output.println(kleur.green("Cache was cleared.\n"))
})
program.command("init <path>")
.description("Initialize a new pack in the directory.")
.action(async path => {
const directoryPath = Path.create(path)
const horizrFilePath = directoryPath.resolve("horizr.json")
if (await fs.pathExists(horizrFilePath.toString())) output.failAndExit(`${kleur.yellow("horizr.json")} already exists in the directory.`)
await fs.mkdirp(directoryPath.toString())
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
const answers: any = await enquirer.prompt([
{
name: "name",
type: "input",
message: "Name",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "authors",
type: "input",
message: "Authors (comma-separated)",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "description",
type: "text",
message: "Description"
},
{
name: "license",
type: "text",
message: "License (SPDX-ID)",
validate: answer => answer.length === 0 ? "An answer is required." : true
},
{
name: "minecraftVersion",
type: "autocomplete",
message: "Minecraft version",
choices: minecraftVersions.map(version => ({
name: version,
value: version
})),
// @ts-expect-error
limit: 10,
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
}
])
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
const file: HorizrFile = {
formatVersion: CURRENT_HORIZR_FILE_FORMAT_VERSION,
meta: {
name: answers.name,
version: "1.0.0",
description: answers.description === "" ? undefined : answers.description,
authors: (answers.authors as string).split(", ").map(a => a.trim()),
license: answers.license
},
versions: {
minecraft: answers.minecraftVersion,
fabric: fabricVersion
}
}
await fs.writeJson(horizrFilePath.toString(), file, { spaces: 2 })
await fs.writeFile(directoryPath.resolve(".gitignore").toString(), "/generated/")
const relativePath = Path.create(process.cwd()).relative(directoryPath).toString()
if (relativePath === "") output.println(kleur.green(`Successfully initialized pack.`))
else output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(relativePath)}.`))
})
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)`}
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.paths.root, 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 mod = pack.findModByCodeOrFail(code)
const update = await output.withLoading(mod.checkForUpdate(allowedReleaseChannels), "Checking for an update")
if (update === null) {
output.println(kleur.green("No update available."))
} else {
if (update.changelog === null) {
output.println(`No changelog available for ${kleur.bold(update.availableVersion)}.`)
} else {
output.println(`${kleur.underline("Changelog")} for ${kleur.bold().yellow(update.availableVersion)}\n`)
output.printlnWrapping(update.changelog)
}
output.println("")
const confirmed = options.yes || await yesno({
question: "Apply the update? [Y/n]",
defaultValue: true,
invalid: () => {}
})
if (confirmed) {
await output.withLoading(update.apply(), "Updating")
output.println(kleur.green(`Successfully updated ${kleur.yellow(update.mod.modFile.name)} to ${kleur.yellow(update.availableVersion)}.`))
}
}
}
})
.addCommand(modrinthCommand)
.addCommand(packwizCommand)
.addCommand(exportJsonSchemasCommand, { hidden: true })
.addCommand(infoCommand)
.addCommand(initCommand)
.addCommand(updateCommand)
loudRejection(stack => {
output.failAndExit(stack)
})
await program
.addCommand(packwizCommand)
.addCommand(modrinthCommand)
.addHelpText("after", "\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:")}
`)
.parseAsync(process.argv)
await program.parseAsync(process.argv)

View file

@ -1,16 +1,20 @@
import { HTTPError, Response } from "got"
import { output } from "../utils/output.js"
import kleur from "kleur"
import { delay, got } from "../utils.js"
import { output } from "../output.js"
import { dependencyToRelatedVersionType } from "./utils.js"
import { ReleaseChannel } from "../shared.js"
import { got } from "../utils/http.js"
import { delay } from "../utils/promises.js"
import { dependencyToRelatedVersionType } from "./index.js"
import { ReleaseChannel } from "../pack.js"
import { orEmptyString } from "../utils/strings.js"
const BASE_URL = "https://api.modrinth.com"
async function getModrinthApiOptional(url: string): Promise<any | null> {
let response: Response
while (true) {
response = await got(url, {
prefixUrl: "https://api.modrinth.com",
prefixUrl: BASE_URL,
throwHttpErrors: false,
retry: {
limit: 3,
@ -56,7 +60,7 @@ async function getModrinthApiOptional(url: string): Promise<any | null> {
async function getModrinthApi(url: string): Promise<any> {
const response = await getModrinthApiOptional(url)
if (response === null) return output.failAndExit("Request failed with status code 404.")
if (response === null) return output.failAndExit(`Request failed with status code 404: ${kleur.yellow(BASE_URL + "/" + url)}`)
return response
}
@ -188,8 +192,8 @@ export const modrinthApi = {
updateDate: new Date(response.updated)
}
},
async listVersions(idOrSlug: string, minecraftVersion: string): Promise<ModrinthVersion[]> {
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`)
async listVersions(idOrSlug: string, minecraftVersion?: string): Promise<ModrinthVersion[]> {
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]${orEmptyString(minecraftVersion, v => `&game_versions=["${v}"]`)}`)
return response.map(transformApiModVersion)
},

61
src/modrinth/exporting.ts Normal file
View file

@ -0,0 +1,61 @@
import { AbsolutePath } from "../utils/path.js"
import { output } from "../utils/output.js"
import fs from "fs-extra"
import { Side, usePack } from "../pack.js"
import kleur from "kleur"
import { mapNotNull } from "../utils/collections.js"
const overridesDirectoryNameBySide: Record<Side, string> = {
client: "client-overrides",
server: "server-overrides",
universal: "overrides"
}
export async function generateOutputDirectory(outputDirectoryPath: AbsolutePath) {
const pack = await usePack()
await fs.remove(outputDirectoryPath.toString())
await fs.mkdirp(outputDirectoryPath.toString())
await fs.writeJson(outputDirectoryPath.resolve("modrinth.index.json").toString(), {
formatVersion: 1,
game: "minecraft",
versionId: pack.manifest.meta.version,
name: pack.manifest.meta.name,
summary: pack.manifest.meta.description,
dependencies: {
minecraft: pack.manifest.versions.minecraft,
"fabric-loader": pack.manifest.versions.fabric
},
files: mapNotNull(pack.metaFiles, metaFile => metaFile.content.enabled ? ({
path: metaFile.effectivePath.toString(),
hashes: {
sha1: metaFile.content.version.hashes.sha1,
sha512: metaFile.content.version.hashes.sha512
},
env: {
client: metaFile.side === "client" || metaFile.side === "universal" ? "required" : "unsupported",
server: metaFile.side === "server" || metaFile.side === "universal" ? "required" : "unsupported"
},
downloads: [
metaFile.content.version.downloadUrl
],
fileSize: metaFile.content.version.size
}) : null)
}, { spaces: 2 })
let i = 0
for (const staticSourceFile of pack.staticSourceFiles) {
i++
const loader = output.startLoading(`Exporting static source file (${i}/${pack.staticSourceFiles.length}): ${kleur.yellow(staticSourceFile.relativePath.toString())}`)
const outputPath = outputDirectoryPath.resolve(overridesDirectoryNameBySide[staticSourceFile.side], staticSourceFile.effectivePath)
await fs.mkdirp(outputPath.parent().toString())
await fs.copy(staticSourceFile.absolutePath.toString(), outputPath.toString())
// Workaround for https://github.com/PolyMC/PolyMC/issues/1060
if (staticSourceFile.side === "client") {
await fs.mkdirp(outputDirectoryPath.resolve(overridesDirectoryNameBySide.universal, staticSourceFile.effectivePath).parent().toString())
}
loader.stop()
}
}

128
src/modrinth/index.ts Normal file
View file

@ -0,0 +1,128 @@
import { IterableElement } from "type-fest"
import { modrinthApi, ModrinthMod, ModrinthVersion, ModrinthVersionFile, ModrinthVersionRelation } from "./api.js"
import { sortBy } from "lodash-es"
import { MetaFile, Pack, releaseChannelOrder } from "../pack.js"
import { MetaFileContentVersion } from "../files.js"
import { output } from "../utils/output.js"
import kleur from "kleur"
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 const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
modrinthVersion.supportedMinecraftVersions.includes(pack.manifest.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric")
export function getMetaFileContentVersionForModrinth(modrinthVersion: ModrinthVersion): MetaFileContentVersion {
const modrinthVersionFile = findCorrectModVersionFile(modrinthVersion.files)
return {
name: modrinthVersion.versionString,
fileName: modrinthVersionFile.fileName,
hashes: {
sha1: modrinthVersionFile.hashes.sha1,
sha512: modrinthVersionFile.hashes.sha512
},
downloadUrl: modrinthVersionFile.url,
size: modrinthVersionFile.sizeInBytes
}
}
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]
}
export const getSideOfModrinthMod = (modrinthMod: ModrinthMod) =>
modrinthMod.serverSide !== "unsupported" && modrinthMod.clientSide !== "unsupported"
? "universal"
: modrinthMod.clientSide !== "unsupported" ? "client" : "server"
export async function resolveModrinthCode(code: string): Promise<{ modrinthMod: ModrinthMod; modrinthVersion: ModrinthVersion | null }> {
const resolveMod = async (slugOrId: string) => {
const modrinthMod = await modrinthApi.getMod(slugOrId)
if (modrinthMod === null) return output.failAndExit(`Unknown mod: ${kleur.yellow(slugOrId)}`)
return {
modrinthMod,
modrinthVersion: null
}
}
const resolveVersionByName = async (modrinthMod: ModrinthMod, name: string) => {
const modrinthVersions = await modrinthApi.listVersions(modrinthMod.id)
const modrinthVersion = modrinthVersions.find(v => v.versionString === name)
if (modrinthVersion === undefined) return output.failAndExit(`Unknown version: ${kleur.yellow(name)}`)
return {
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
modrinthVersion
}
}
const resolveVersion = async (id: string) => {
const modrinthVersion = await modrinthApi.getVersion(id)
if (modrinthVersion === null) return output.failAndExit(`Unknown version: ${kleur.yellow(id)}`)
return {
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
modrinthVersion
}
}
const parts = code.split("@")
if (parts.length === 2 && parts[0] === "") return resolveVersion(code.slice(1))
if (parts.length <= 2 && !code.startsWith("https://")) {
const value = await resolveMod(parts[0])
if (parts.length === 2) return resolveVersionByName(value.modrinthMod, parts[1])
else return value
}
try {
const url = new URL(code)
const pathSegments = url.pathname.slice(1).split("/")
if (!(code.startsWith("https://modrinth.com/mod/") && (pathSegments.length === 2 || pathSegments.length === 4)))
output.failAndExit("Only Modrinth mod and version URLs are supported.")
const value = await resolveMod(pathSegments[1])
if (pathSegments.length === 4) return resolveVersionByName(value.modrinthMod, pathSegments[3])
else return value
} catch (e: unknown) {
// TypeError means code is not a URL
if (!(e instanceof TypeError)) throw e
}
return output.failAndExit(`Invalid ${kleur.yellow("<code>")}: ${kleur.yellow(code)}`)
}
export const findMetaFileForModrinthMod = (metaFiles: MetaFile[], modrinthModId: string) =>
metaFiles.find(metaFile => metaFile.content.source?.type === "modrinth" && metaFile.content.source.modId === modrinthModId) ?? null
export async function resolveFullRelation(relation: ModrinthVersionRelation) {
if (relation.projectId === null) {
const modrinthVersion = (await modrinthApi.getVersion(relation.versionId!))!
return {
modrinthVersion,
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!
}
} else {
const modrinthMod = (await modrinthApi.getMod(relation.projectId))!
return {
modrinthMod,
modrinthVersion: null
}
}
}

56
src/modrinth/updating.ts Normal file
View file

@ -0,0 +1,56 @@
import { MetaFile, ReleaseChannel, Update } from "../pack.js"
import { MetaFileModrinthSource } from "../files.js"
import { modrinthApi, ModrinthVersion } from "./api.js"
import semver from "semver"
import { getMetaFileContentVersionForModrinth } from "./index.js"
import { sortBy } from "lodash-es"
async function fetchNewerModrinthVersions(
activeVersion: string,
source: MetaFileModrinthSource,
allowedReleaseChannels: ReleaseChannel[],
minecraftVersion: string
): Promise<ModrinthVersion[]> {
const activeSemver = semver.parse(activeVersion)
const availableVersions = await modrinthApi.listVersions(source.modId, minecraftVersion)
const allowedVersions = availableVersions.filter(version => allowedReleaseChannels.includes(version.releaseChannel))
if (activeSemver === null) {
const activePublicationDate = allowedVersions.find(v => v.id === source.versionId)?.publicationDate
if (activePublicationDate === undefined) return allowedVersions
return allowedVersions.filter(v => v.publicationDate.toISOString() > activePublicationDate.toISOString())
} else {
return allowedVersions.filter(version => {
const thisSemver = semver.parse(version.versionString)
// If mods switch to a non-SemVer version scheme, all new versions are considered older.
// This may be a problem.
if (thisSemver === null) return false
return thisSemver.compare(activeSemver) === 1
})
}
}
export async function fetchModrinthModUpdates(
metaFile: MetaFile,
source: MetaFileModrinthSource,
allowedReleaseChannels: ReleaseChannel[],
minecraftVersion: string
): Promise<Update[]> {
const sorted = sortBy(await fetchNewerModrinthVersions(metaFile.content.version.name, source, allowedReleaseChannels, minecraftVersion), v => v.publicationDate.toISOString())
.reverse()
return sorted.map(modrinthVersion => ({
of: metaFile,
versionString: modrinthVersion.versionString,
changelog: modrinthVersion.changelog,
async apply() {
metaFile.content.version = getMetaFileContentVersionForModrinth(modrinthVersion)
source.versionId = modrinthVersion.id
await metaFile.saveContent()
}
}))
}

View file

@ -1,87 +0,0 @@
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("fabric")
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.paths.mods.resolve(`${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]
}

View file

@ -1,173 +1,165 @@
import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
import { output } from "./output.js"
import pLimit from "p-limit"
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
import {
findPackDirectoryPath,
PackManifest,
packManifestFileSchema,
MetaFileContent,
PACK_MANIFEST_FILE_NAME,
metaFileContentSchema,
writeJsonFile,
listSourceFiles,
readJsonFile, META_FILE_EXTENSION
} from "./files.js"
import { z, ZodRawShape } from "zod"
import { fetchModrinthModUpdates } from "./modrinth/updating.js"
import { createCpuCoreLimiter } from "./utils/promises.js"
import { output } from "./utils/output.js"
import pathModule from "path"
import kleur from "kleur"
import { modrinthApi } from "./modrinth/api.js"
import semver from "semver"
import { Path } from "./path.js"
import { ReleaseChannel, Side, sides } from "./shared.js"
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
import { orEmptyString } from "./utils/strings.js"
import fs from "fs-extra"
export type ReleaseChannel = "alpha" | "beta" | "release"
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
export type Side = "client" | "server" | "universal"
export const sides: [Side, ...Side[]] = ["client", "server", "universal"]
export interface Pack {
manifest: PackManifest
paths: {
root: AbsolutePath
source: AbsolutePath
exports: AbsolutePath
}
metaFiles: MetaFile[]
staticSourceFiles: StaticSourceFile[]
getMetaFile(path: RelativePath): MetaFile | null
getMetaFileFromInput(input: string): MetaFile
getEffectiveMetaFile(path: RelativePath, side: Side): MetaFile | null
registerCreatedSourceFile(path: RelativePath): Promise<void>
readSourceJsonFile<S extends z.ZodObject<ZodRawShape>>(path: RelativePath, schema: S): Promise<z.output<S> | null>
}
export interface SourceFile {
isStatic: boolean
isMod: boolean
side: Side
relativePath: RelativePath
absolutePath: AbsolutePath
effectivePath: RelativePath
}
export interface MetaFile extends SourceFile {
isStatic: false
content: MetaFileContent
fetchUpdates: null | ((allowedReleaseChannels: ReleaseChannel[]) => Promise<Update[]>)
saveContent(): Promise<void>
getDisplayString(): string
}
export interface StaticSourceFile extends SourceFile {
isStatic: true
}
export interface Update {
mod: Mod
activeVersion: string
availableVersion: string
of: MetaFile
versionString: string
changelog: string | null
apply(): Promise<void>
}
export interface Pack {
paths: {
root: Path,
mods: Path,
generated: Path,
overrides: Record<Side, Path>
},
horizrFile: HorizrFile
mods: Mod[]
addMod(id: string, file: ModFile): Promise<void>
findModByCode(code: string): Mod | null
findModByCodeOrFail(code: string): Mod
validateOverridesDirectories(): Promise<void>
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
}
export interface Mod {
id: string
modFile: ModFile
saveModFile(): Promise<void>
checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null>
}
export const EXPORTS_DIRECTORY_NAME = "exports"
let pack: Pack
export async function usePack(): Promise<Pack> {
if (pack === undefined) {
const rootDirectoryPath = await findPackDirectoryPath()
const overridesDirectoryPath = rootDirectoryPath.resolve("overrides")
const sourceDirectoryPath = rootDirectoryPath.resolve("src")
const readSourceJsonFile: Pack["readSourceJsonFile"] = async (path, schema) => readJsonFile(sourceDirectoryPath, path, schema)
const manifest = (await readJsonFile(rootDirectoryPath, RelativePath.create(PACK_MANIFEST_FILE_NAME), packManifestFileSchema))!
const metaFiles: MetaFile[] = []
const staticSourceFiles: StaticSourceFile[] = []
const registerSourceFile: Pack["registerCreatedSourceFile"] = async relativePath => {
const absolutePath = sourceDirectoryPath.resolve(relativePath)
if (!await fs.pathExists(absolutePath.toString())) throw new Error("File does not exist: " + absolutePath)
const pathSegments = relativePath.toString().split("/")
const sourceFile: SourceFile = {
isStatic: false,
isMod: pathSegments[1] === "mods",
side: pathSegments[0] as Side,
relativePath: relativePath,
absolutePath: sourceDirectoryPath.resolve(relativePath),
effectivePath: RelativePath._createDirect(pathSegments.slice(1).join("/"))
}
if (relativePath.toString().endsWith("." + META_FILE_EXTENSION)) {
const content = (await readSourceJsonFile(relativePath, metaFileContentSchema))!
const { source } = content
const metaFile: MetaFile = {
...sourceFile,
isStatic: false,
effectivePath: sourceFile.effectivePath.parent().joinedWith(content.version.fileName),
content,
fetchUpdates: source?.type === "modrinth"
? allowedReleaseChannels => fetchModrinthModUpdates(metaFile, source, allowedReleaseChannels, manifest.versions.minecraft)
: null,
async saveContent() {
await writeJsonFile(sourceDirectoryPath.resolve(relativePath), metaFileContentSchema, this.content)
},
getDisplayString: () => `${kleur.yellow(metaFile.relativePath.toString())}${orEmptyString(metaFile.content.displayName, v => " " + kleur.blue(v))}`
}
metaFiles.push(metaFile)
} else {
staticSourceFiles.push({
...sourceFile,
isStatic: true
})
}
}
const sourceFilePaths = await listSourceFiles(sourceDirectoryPath)
const limit = createCpuCoreLimiter()
await Promise.all(sourceFilePaths.map(path => limit(() => registerSourceFile(path))))
pack = {
paths: {
root: rootDirectoryPath,
generated: rootDirectoryPath.resolve("generated"),
mods: rootDirectoryPath.resolve("mods"),
overrides: {
client: overridesDirectoryPath.resolve("client"),
server: overridesDirectoryPath.resolve("server"),
"client-server": overridesDirectoryPath.resolve("client-server")
}
source: sourceDirectoryPath,
exports: rootDirectoryPath.resolve(EXPORTS_DIRECTORY_NAME)
},
horizrFile: await readHorizrFile(rootDirectoryPath),
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
const mod: Mod = {
id,
modFile: (await readModFile(rootDirectoryPath, id))!,
async saveModFile() {
await writeModFile(rootDirectoryPath, id, this.modFile)
manifest,
metaFiles,
staticSourceFiles,
readSourceJsonFile,
registerCreatedSourceFile: registerSourceFile,
getMetaFile(relativePath: RelativePath) {
return metaFiles.find(metaFile => metaFile.relativePath.is(relativePath)) ?? null
},
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.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,
changelog: newestVersion.changelog,
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(rootDirectoryPath, id, file)
getEffectiveMetaFile(effectivePath: RelativePath, side: Side) {
return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? null
},
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 validateOverridesDirectories() {
const dirents = await getOverrideDirents(overridesDirectoryPath)
getMetaFileFromInput(input: string): MetaFile {
const path = envPaths.cwd.resolveAny(input)
if (!path.isDescendantOf(sourceDirectoryPath)) output.failAndExit(`${kleur.yellow(pathModule.normalize(input))} is outside the source directory.`)
const notDirectories = dirents.filter(dirent => !dirent.isDirectory())
if (notDirectories.length !== 0)
output.failAndExit(
`The ${kleur.yellow("overrides")} directory contains files that are not directories:\n${notDirectories.slice(0, 5).map(e => `- ${e.name}`).join("\n")}` +
(notDirectories.length > 5 ? `\n${kleur.gray(`and ${notDirectories.length - 5} more`)}` : "") +
`\n\nAll files must reside in one of these sub-directories: ${sides.map(kleur.yellow).join(", ")}`
)
const relativePath = sourceDirectoryPath.relativeTo(path.toString().endsWith("." + META_FILE_EXTENSION) ? path : (path.toString() + "." + META_FILE_EXTENSION))
if (dirents.some(dirent => !(sides as string[]).includes(dirent.name)))
output.failAndExit(`The ${kleur.yellow("overrides")} directory may only contain the following sub-directories:\n${sides.map(side => `- ${side}`).join("\n")}`)
},
async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]> {
const limit = pLimit(5)
const metaFile = this.getMetaFile(relativePath)
if (metaFile === null) return output.failAndExit(`${kleur.yellow(relativePath.toString())} does not exist.`)
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[]
return metaFile
}
}
}

104
src/packwiz/exporting.ts Normal file
View file

@ -0,0 +1,104 @@
import { AbsolutePath, RelativePath } from "../utils/path.js"
import dedent from "dedent"
import fs from "fs-extra"
import { MetaFile, StaticSourceFile, usePack } from "../pack.js"
import pathModule from "path"
import { computeSha512HexHash, computeSha512HexHashForFile } from "../utils/misc.js"
import { orEmptyString } from "../utils/strings.js"
import { META_FILE_EXTENSION } from "../files.js"
export const PACKWIZ_EXPORT_DIRECTORY_NAME = "packwiz"
export interface IndexedFile {
path: RelativePath
sha512HashHex: string
isMeta: boolean
}
export async function writeAndIndexStaticSourceFile(
indexedFiles: IndexedFile[],
outputDirectoryPath: AbsolutePath,
staticSourceFile: StaticSourceFile
) {
const outputPath = outputDirectoryPath.resolve(staticSourceFile.effectivePath)
await fs.mkdirp(outputPath.parent().toString())
await fs.copy(staticSourceFile.absolutePath.toString(), outputPath.toString())
indexedFiles.push({
path: staticSourceFile.effectivePath,
isMeta: false,
sha512HashHex: await computeSha512HexHashForFile(outputPath)
})
}
export async function writeAndIndexMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath, metaFile: MetaFile) {
const updateSection = metaFile.content.source?.type === "modrinth"
? dedent`
\n\n[update]
[update.modrinth]
mod-id = ${JSON.stringify(metaFile.content.source.modId)}
version = ${JSON.stringify(metaFile.content.source.versionId)}
`
: ""
const content = dedent`
name = ${JSON.stringify(metaFile.content.displayName ?? pathModule.basename(metaFile.relativePath.toString()))}
filename = ${JSON.stringify(metaFile.content.version.fileName)}
side = "${metaFile.side.replace("universal", "both")}"
[download]
hash-format = "sha512"
hash = ${JSON.stringify(metaFile.content.version.hashes.sha512)}
url = ${JSON.stringify(metaFile.content.version.downloadUrl)}${updateSection}
`
const relativeOutputPath = metaFile.effectivePath
.parent()
.joinedWith(metaFile.absolutePath.getBasename(META_FILE_EXTENSION) + ".toml")
const outputPath = outputDirectoryPath.resolve(relativeOutputPath)
await fs.mkdirp(outputPath.parent().toString())
await fs.writeFile(outputPath.toString(), content)
indexedFiles.push({
path: relativeOutputPath,
isMeta: true,
sha512HashHex: await computeSha512HexHash(content)
})
}
export async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath) {
const pack = await usePack()
const index = dedent`
hash-format = "sha512"
${indexedFiles.map(file => dedent`
[[files]]
file = ${JSON.stringify(file.path.toString())}
hash = "${file.sha512HashHex}"
metafile = ${file.isMeta}
`).join("\n\n")}
`
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
const indexHash = await computeSha512HexHash(index)
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
name = ${JSON.stringify(pack.manifest.meta.name)}
author = ${JSON.stringify(pack.manifest.meta.authors.join(", "))}\
${orEmptyString(pack.manifest.meta.description, d => `\ndescription = ${JSON.stringify(d)}`)}
pack-format = "packwiz:1.1.0"
[versions]
minecraft = ${JSON.stringify(pack.manifest.versions.minecraft)}
fabric = ${JSON.stringify(pack.manifest.versions.fabric)}
[index]
file = "index.toml"
hash-format = "sha512"
hash = "${indexHash}"
`)
}

View file

@ -1,61 +0,0 @@
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)[]) {
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 | string) {
return new Path(pathModule.relative(this.value, typeof other === "string" ? other : other.toString()))
}
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)
}

View file

@ -1,5 +0,0 @@
export type ReleaseChannel = "alpha" | "beta" | "release"
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
export type Side = "client" | "server" | "client-server"
export const sides: [Side, ...Side[]] = ["client", "server", "client-server"]

View file

@ -1,117 +0,0 @@
import { InvalidArgumentError } from "commander"
import hash, { HashaInput } from "hasha"
import { Path, paths } from "./path.js"
import { ZipFile } from "yazl"
import { walk } from "@root/walk"
import fs from "fs-extra"
import { pEvent } from "p-event"
import serveHandler from "serve-handler"
import * as http from "http"
import addressWithCallback from "address"
import { promisify } from "util"
import { KeyvFile } from "keyv-file"
import originalGot from "got"
const keyvCache = new KeyvFile({
filename: paths.cache.resolve("http.json").toString(),
writeDelay: 50,
expiredCheckDelay: 24 * 3600 * 1000,
encode: JSON.stringify,
decode: JSON.parse
})
export const clearCache = () => keyvCache.clear()
export const got = originalGot.extend({
cache: keyvCache,
responseType: "json",
headers: {
"User-Agent": "moritzruth/horizr/1.0.0 (not yet public)"
}
})
const address = promisify(addressWithCallback)
export const getLANAddress = () => address().then(r => r.ip)
export function createSingleConcurrencyWithQueue(fn: () => Promise<void>) {
let state: "inactive" | "running_fresh" | "running_old" = "inactive"
return async () => {
if (state === "inactive") {
const loop = () => {
state = "running_fresh"
fn().then(() => {
if (state === "running_old") loop()
})
}
loop()
} else {
state = "running_old"
}
}
}
export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) {
const server = http.createServer((request, response) => {
return serveHandler(request, response, {
directoryListing: false,
public: path.toString(),
cleanUrls: false,
headers: [
{
source: "**/*.toml",
headers: [{
key: "Content-Type",
value: "application/toml"
}]
}
]
})
})
server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => {
onListen()
})
}
export async function zipDirectory(directoryPath: Path, outputFilePath: Path) {
const zipFile = new ZipFile()
zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString()))
await walk(directoryPath.toString(), async (error, path, dirent) => {
if (error) return
if (directoryPath.toString() === path) return true
if (dirent.name.startsWith(".")) return false
if (dirent.isFile()) zipFile.addFile(path, directoryPath.relative(path).toString(), { compress: true })
})
zipFile.end()
await pEvent(zipFile.outputStream, "close")
}
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
}

16
src/utils/collections.ts Normal file
View file

@ -0,0 +1,16 @@
export function mapNotNull<T, R>(array: T[], fn: (item: T, index: number) => R | null): R[] {
const result: R[] = []
let index = 0
for (const item of array) {
const mapped = fn(item, index)
if (mapped !== null) result.push(mapped)
index++
}
return result
}
export function filterNulls<T>(array: T[]): Exclude<T, null>[] {
return array.filter(i => i !== null) as Exclude<T, null>[]
}

69
src/utils/http.ts Normal file
View file

@ -0,0 +1,69 @@
import { KeyvFile } from "keyv-file"
import { Path, envPaths } from "./path.js"
import originalGot from "got"
import http from "http"
import serveHandler from "serve-handler"
import { getLANAddress } from "./misc.js"
import { output } from "./output.js"
import dedent from "dedent"
import kleur from "kleur"
const keyvCache = new KeyvFile({
filename: envPaths.cache.resolve("http.json").toString(),
writeDelay: 50,
expiredCheckDelay: 24 * 3600 * 1000,
encode: JSON.stringify,
decode: JSON.parse
})
export const clearGotCache = () => keyvCache.clear()
export const got = originalGot.extend({
cache: keyvCache,
responseType: "json",
headers: {
"User-Agent": "moritzruth/horizr/1.0.0 (not yet public)"
}
})
export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) {
const server = http.createServer((request, response) => {
return serveHandler(request, response, {
directoryListing: false,
public: path.toString(),
cleanUrls: false,
headers: [
{
source: "**/*.toml",
headers: [{
key: "Content-Type",
value: "application/toml"
}]
}
]
})
})
server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => {
onListen()
})
}
export async function httpServeDirectoryWithMessage(path: Path, port: number, expose: boolean) {
const lanAddress = await getLANAddress()
const localAddress = `http://localhost:${port}`
await new Promise<void>(resolve => {
httpServeDirectory(path, port, expose, () => {
if (expose) {
output.println(dedent`
${kleur.green("Serving at")}
Local: ${kleur.yellow(localAddress)}
Network: ${kleur.yellow(`http://${lanAddress}:${port}`)}
`)
} else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`)
resolve()
})
})
}

10
src/utils/misc.ts Normal file
View file

@ -0,0 +1,10 @@
import { promisify } from "util"
import addressWithCallback from "address"
import hash, { HashaInput } from "hasha"
import { AbsolutePath } from "./path.js"
const address = promisify(addressWithCallback)
export const getLANAddress = () => address().then(r => r.ip)
export const computeSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" })
export const computeSha512HexHashForFile = (path: AbsolutePath) => hash.fromFile(path.toString(), { algorithm: "sha512", encoding: "hex" })

29
src/utils/options.ts Normal file
View file

@ -0,0 +1,29 @@
import { InvalidArgumentError } from "commander"
import { Side, sides } from "../pack.js"
export function integerOption(value: string): number {
const parsed = parseInt(value, 10)
if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.")
return parsed
}
export function positiveIntegerOption(value: string): number {
const parsed = parseInt(value, 10)
if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.")
return parsed
}
export function gtzIntegerOption(value: string): number {
const parsed = parseInt(value, 10)
if (isNaN(parsed) || parsed <= 0) throw new InvalidArgumentError("Must be an integer > 0.")
return parsed
}
export function sideOption(value: string): Side {
if (!(sides as string[]).includes(value)) throw new InvalidArgumentError(`Must be one of ${sides.join(", ")}`)
return value as Side
}

View file

@ -140,14 +140,13 @@ export const output = {
}
},
println(text: string) {
this.print(text + "\n")
this.print(text + "\n\n")
},
printlnWrapping(text: string) {
this.println(wrapAnsi(text, process.stdout.columns))
},
warn(text: string) {
this.printlnWrapping(`${kleur.yellow(figures.pointer)} ${text}`)
this.println("")
},
fail(text: string) {
last(loadersStack)?.fail()

131
src/utils/path.ts Normal file
View file

@ -0,0 +1,131 @@
import pathModule from "path"
import getEnvPaths from "env-paths"
interface AbstractPath {
isDescendantOf(other: Path): boolean
is(other: Path | string): boolean
getBasename(): string
toAbsolute(): AbsolutePath
toString(): string
}
export type Path = AbsolutePath | RelativePath
export class RelativePath implements AbstractPath {
private constructor(private readonly pathString: string) {
}
isDescendantOf(other: Path) {
return this.pathString !== "" && !this.pathString.split("/").includes("..")
}
resolveInCwd(...segments: (string | RelativePath)[]): AbsolutePath {
return AbsolutePath._createDirect(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
}
joinedWith(...segments: (string | Path)[]): RelativePath {
return RelativePath._createDirect(pathModule.join(this.pathString, ...segments.map(s => s.toString())))
}
parent(): RelativePath {
return RelativePath._createDirect(pathModule.dirname(this.pathString))
}
is(other: Path | string): boolean {
return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString())
}
getBasename(): string {
return pathModule.basename(this.pathString)
}
toAbsolute(): AbsolutePath {
return envPaths.cwd.resolve(this)
}
toString(): string {
return this.pathString
}
static create(pathString: string) {
if (pathModule.isAbsolute(pathString)) throw new Error("pathString is not relative")
return new RelativePath(pathModule.normalize(pathString))
}
static _createDirect(pathString: string) {
return new RelativePath(pathString)
}
}
export class AbsolutePath implements AbstractPath {
private constructor(private readonly pathString: string) {
}
isDescendantOf(other: Path) {
if (other instanceof AbsolutePath) {
return other.relativeTo(this).isDescendantOf(this)
} else return other.isDescendantOf(this)
}
resolve(...segments: (string | RelativePath)[]): AbsolutePath {
return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
}
resolveAny(...segments: (string | Path)[]): AbsolutePath {
return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
}
joinedWith(...segments: (string | RelativePath)[]): AbsolutePath {
return new AbsolutePath(pathModule.join(this.pathString, ...segments.map(s => s.toString())))
}
parent(): AbsolutePath {
return new AbsolutePath(pathModule.dirname(this.pathString))
}
relativeTo(other: Path | string): RelativePath {
if (other instanceof RelativePath) return other
else return RelativePath._createDirect(pathModule.relative(this.pathString, typeof other === "string" ? other : other.toString()))
}
is(other: Path | string): boolean {
return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString())
}
getBasename(stripExtension?: string): string {
return pathModule.basename(this.pathString, "." + stripExtension)
}
/**
* @deprecated Unnecessary.
*/
toAbsolute(): AbsolutePath {
return this
}
toString(): string {
return this.pathString
}
static create(pathString: string) {
if (!pathModule.isAbsolute(pathString)) throw new Error("pathString is not absolute")
return new AbsolutePath(pathModule.normalize(pathString))
}
static _createDirect(pathString: string) {
return new AbsolutePath(pathString)
}
}
const rawPaths = getEnvPaths("horizr", { suffix: "" })
export const envPaths = {
cache: AbsolutePath.create(rawPaths.cache),
config: AbsolutePath.create(rawPaths.config),
data: AbsolutePath.create(rawPaths.data),
log: AbsolutePath.create(rawPaths.log),
temp: AbsolutePath.create(rawPaths.temp),
cwd: AbsolutePath.create(process.cwd())
}

26
src/utils/promises.ts Normal file
View file

@ -0,0 +1,26 @@
import pLimit from "p-limit"
import os from "os"
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export const createCpuCoreLimiter = () => pLimit(os.cpus().length)
export function createSingleConcurrencyWithQueue(fn: () => Promise<void>) {
let state: "inactive" | "running_fresh" | "running_old" = "inactive"
return async () => {
if (state === "inactive") {
const loop = () => {
state = "running_fresh"
fn().then(() => {
if (state === "running_old") loop()
})
}
loop()
} else {
state = "running_old"
}
}
}

9
src/utils/strings.ts Normal file
View file

@ -0,0 +1,9 @@
export const orEmptyString =
<T>(value: T, fn: (v: Exclude<T, undefined | null>) => string): string =>
value === undefined || value === null ? "" : fn(value as Exclude<T, undefined | null>)
export function truncateWithEllipsis(text: string, maxLength: number) {
if (text.length <= maxLength) return text
return text.slice(0, maxLength - 1).trimEnd() + "…"
}

32
src/utils/zip.ts Normal file
View file

@ -0,0 +1,32 @@
import { AbsolutePath } from "./path.js"
import { ZipFile } from "yazl"
import fs from "fs-extra"
import { walk } from "@root/walk"
import { without } from "lodash-es"
import { pEvent } from "p-event"
import { dirname } from "path"
export async function zipDirectory(directoryPath: AbsolutePath, outputFilePath: AbsolutePath) {
const zipFile = new ZipFile()
zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString()))
let emptyDirectories: string[] = []
await walk(directoryPath.toString(), async (error, path, dirent) => {
if (error) return
if (directoryPath.toString() === path) return true
if (dirent.name.startsWith(".")) return false
if (dirent.isDirectory()) {
emptyDirectories.push(path)
} else if (dirent.isFile()) {
zipFile.addFile(path, directoryPath.relativeTo(path).toString(), { compress: true })
} else return
emptyDirectories = without(emptyDirectories, dirname(path))
})
emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relativeTo(p).toString()))
zipFile.end()
await pEvent(zipFile.outputStream, "close")
}

View file

@ -1 +1 @@
/generated/
/exports/

View file

@ -1,5 +1,6 @@
{
"formatVersion": 1,
"slug": "test",
"meta": {
"name": "Test",
"version": "1.0.0",

View file

@ -1,21 +0,0 @@
{
"name": "Fabric API",
"enabled": true,
"ignoreUpdates": false,
"side": "client-server",
"file": {
"version": "0.58.0+1.18.2",
"name": "fabric-api-0.58.0+1.18.2.jar",
"size": 1445029,
"downloadUrl": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar",
"hashes": {
"sha1": "b9ab9ab267f8cdff525f9a8edb26435d3e2455f6",
"sha512": "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed"
}
},
"source": {
"type": "modrinth",
"modId": "P7dR8mSH",
"versionId": "4XRtXhtL"
}
}

View file

@ -1,12 +1,10 @@
{
"name": "Sodium",
"formatVersion": 1,
"enabled": true,
"ignoreUpdates": false,
"side": "client",
"file": {
"version": "mc1.18.2-0.4.1",
"name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
"version": {
"name": "mc1.18.2-0.4.1",
"size": 1318645,
"fileName": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
"downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar",
"hashes": {
"sha1": "f839863a6be7014b8d80058ea1f361521148d049",
@ -16,6 +14,7 @@
"source": {
"type": "modrinth",
"modId": "AANobbMI",
"versionId": "74Y5Z8fo"
"versionId": "74Y5Z8fo",
"ignoreUpdates": false
}
}

View file

@ -0,0 +1,15 @@
{
"formatVersion": 1,
"displayName": "Better Leaves",
"enabled": true,
"version": {
"name": "7.0.0",
"fileName": "Better-Leaves.zip",
"size": 447282,
"downloadUrl": "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip",
"hashes": {
"sha1": "b768ea104fbf268ab69fcdc4004c7f36df15c545",
"sha512": "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817"
}
}
}

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1,20 @@
{
"formatVersion": 1,
"enabled": true,
"version": {
"name": "mc1.18.2-0.7.10",
"size": 466196,
"fileName": "lithium-fabric-mc1.18.2-0.7.10.jar",
"downloadUrl": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.18.2-0.7.10/lithium-fabric-mc1.18.2-0.7.10.jar",
"hashes": {
"sha1": "d5c19c3d4edb4228652adcc8abb94f9bd80a634c",
"sha512": "05f0e51191c9051224c791d63ad4b7915e6f3c442e5d38225e7b05ea4261ee459edb3d8ce99411e1a5a854547549845f21cc8ee2f0079281fec999c1d319fb07"
}
},
"source": {
"type": "modrinth",
"modId": "gvQqBUqZ",
"versionId": "pHl1Vi6k",
"ignoreUpdates": false
}
}

View file

@ -1,12 +1,11 @@
{
"name": "Charm",
"formatVersion": 1,
"displayName": "Charm",
"enabled": true,
"ignoreUpdates": false,
"side": "client-server",
"file": {
"version": "4.2.0+1.18.2",
"name": "charm-fabric-1.18.2-4.2.0.jar",
"version": {
"name": "4.2.0+1.18.2",
"size": 3413876,
"fileName": "charm-fabric-1.18.2-4.2.0.jar",
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
"hashes": {
"sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82",
@ -16,6 +15,7 @@
"source": {
"type": "modrinth",
"modId": "pOQTcQmj",
"versionId": "BT9G1Jjs"
"versionId": "BT9G1Jjs",
"ignoreUpdates": false
}
}