I remembered I should commit every once in a while

This commit is contained in:
Moritz Ruth 2022-08-18 12:31:18 +02:00
parent fd7e154b5e
commit 585fd43708
52 changed files with 1386 additions and 1035 deletions

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

@ -24,6 +24,7 @@
"dedent": "^0.7.0", "dedent": "^0.7.0",
"enquirer": "^2.3.6", "enquirer": "^2.3.6",
"env-paths": "^3.0.0", "env-paths": "^3.0.0",
"fast-glob": "^3.2.11",
"figures": "^5.0.0", "figures": "^5.0.0",
"find-up": "^6.3.0", "find-up": "^6.3.0",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",

20
pnpm-lock.yaml generated
View file

@ -16,6 +16,7 @@ specifiers:
del-cli: ^5.0.0 del-cli: ^5.0.0
enquirer: ^2.3.6 enquirer: ^2.3.6
env-paths: ^3.0.0 env-paths: ^3.0.0
fast-glob: ^3.2.11
figures: ^5.0.0 figures: ^5.0.0
find-up: ^6.3.0 find-up: ^6.3.0
fs-extra: ^10.1.0 fs-extra: ^10.1.0
@ -47,6 +48,7 @@ dependencies:
dedent: 0.7.0 dedent: 0.7.0
enquirer: 2.3.6 enquirer: 2.3.6
env-paths: 3.0.0 env-paths: 3.0.0
fast-glob: 3.2.11
figures: 5.0.0 figures: 5.0.0
find-up: 6.3.0 find-up: 6.3.0
fs-extra: 10.1.0 fs-extra: 10.1.0
@ -141,12 +143,10 @@ packages:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0 run-parallel: 1.2.0
dev: true
/@nodelib/fs.stat/2.0.5: /@nodelib/fs.stat/2.0.5:
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
dev: true
/@nodelib/fs.walk/1.2.8: /@nodelib/fs.walk/1.2.8:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
@ -154,7 +154,6 @@ packages:
dependencies: dependencies:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.13.0 fastq: 1.13.0
dev: true
/@root/walk/1.1.0: /@root/walk/1.1.0:
resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==} resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==}
@ -323,7 +322,6 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dependencies: dependencies:
fill-range: 7.0.1 fill-range: 7.0.1
dev: true
/buffer-crc32/0.2.13: /buffer-crc32/0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
@ -801,7 +799,6 @@ packages:
glob-parent: 5.1.2 glob-parent: 5.1.2
merge2: 1.4.1 merge2: 1.4.1
micromatch: 4.0.5 micromatch: 4.0.5
dev: true
/fast-url-parser/1.1.3: /fast-url-parser/1.1.3:
resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==}
@ -813,7 +810,6 @@ packages:
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
dependencies: dependencies:
reusify: 1.0.4 reusify: 1.0.4
dev: true
/figures/5.0.0: /figures/5.0.0:
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
@ -828,7 +824,6 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
dev: true
/find-up/5.0.0: /find-up/5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
@ -905,7 +900,6 @@ packages:
engines: {node: '>= 6'} engines: {node: '>= 6'}
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
dev: true
/glob/7.2.3: /glob/7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
@ -1032,14 +1026,12 @@ packages:
/is-extglob/2.1.1: /is-extglob/2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true
/is-glob/4.0.3: /is-glob/4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
dev: true
/is-interactive/2.0.0: /is-interactive/2.0.0:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
@ -1049,7 +1041,6 @@ packages:
/is-number/7.0.0: /is-number/7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
dev: true
/is-path-cwd/3.0.0: /is-path-cwd/3.0.0:
resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==} resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==}
@ -1214,7 +1205,6 @@ packages:
/merge2/1.4.1: /merge2/1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
dev: true
/micromatch/4.0.5: /micromatch/4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
@ -1222,7 +1212,6 @@ packages:
dependencies: dependencies:
braces: 3.0.2 braces: 3.0.2
picomatch: 2.3.1 picomatch: 2.3.1
dev: true
/mime-db/1.33.0: /mime-db/1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
@ -1422,7 +1411,6 @@ packages:
/picomatch/2.3.1: /picomatch/2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
dev: true
/pump/3.0.0: /pump/3.0.0:
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
@ -1437,7 +1425,6 @@ packages:
/queue-microtask/1.2.3: /queue-microtask/1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/quick-lru/5.1.1: /quick-lru/5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
@ -1505,7 +1492,6 @@ packages:
/reusify/1.0.4: /reusify/1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
/rimraf/3.0.2: /rimraf/3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
@ -1518,7 +1504,6 @@ packages:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
dev: true
/s-ago/2.2.0: /s-ago/2.2.0:
resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==} resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==}
@ -1632,7 +1617,6 @@ packages:
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
dev: true
/toml/3.0.0: /toml/3.0.0:
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}

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)}
`)
})

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

@ -0,0 +1,82 @@
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, PACK_MANIFEST_FORMAT_VERSION, PackManifest } from "../files.js"
import pathModule from "path"
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: PACK_MANIFEST_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(manifestFilePath.toString(), file, { spaces: 2 })
await fs.writeFile(path.resolve(".gitignore").toString(), "/generated/")
output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(pathModule.normalize(pathString))}`))
})

View file

@ -0,0 +1,82 @@
import { Command } from "commander"
import { sideOption } from "../../utils/options.js"
import {
findMetaFileForModrinthMod,
getMetaFileContentVersionForModrinth,
getSideOfModrinthMod,
isModrinthVersionCompatible,
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 { META_FILE_EXTENSION, metaFileContentSchema, writeJsonFile } from "../../files.js"
import fs from "fs-extra"
import enquirer from "enquirer"
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)
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, {
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())}`))
})

View file

@ -0,0 +1,16 @@
import { Command } from "commander"
import { activateCommand } from "./activate.js"
import dedent from "dedent"
import kleur from "kleur"
export const modrinthCommand = new Command("modrinth")
.alias("mr")
.addCommand(activateCommand)
.addHelpText("after", dedent`
${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

@ -1,229 +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"
import toml from "toml"
import { addModrinthMod } from "../modrinth/utils.js"
import { modrinthApi } from "../modrinth/api.js"
const packwizCommand = new Command("packwiz")
.alias("pw")
packwizCommand.command("import <path>")
.description("Import the mods of a packwiz pack. Overrides are ignored.")
.addHelpText("after", kleur.red("This command should only be used in newly created packs. Otherwise, the behaviour is undefined."))
.action(async path => {
const packDirectoryPath = Path.create(path)
const modsDirectoryPath = packDirectoryPath.resolve("mods")
if (!await fs.pathExists(modsDirectoryPath.toString())) output.failAndExit(`The pack does not contain a ${kleur.yellow("mods")} directory.`)
const modFileNames = (await fs.readdir(modsDirectoryPath.toString(), { withFileTypes: true }))
.filter(dirent => dirent.isFile() && dirent.name.endsWith(".toml"))
.map(dirent => dirent.name)
let index = 0
for (const modFileName of modFileNames) {
const content = toml.parse(await fs.readFile(modsDirectoryPath.resolve(modFileName).toString(), "utf-8"))
const modrinthVersionId = content.update?.modrinth?.version
if (modrinthVersionId === undefined) output.warn(`${kleur.yellow(modFileName)} has no Modrinth version ID associated. It will not be imported.`)
else {
const modrinthVersion = (await output.withLoading(modrinthApi.getVersion(modrinthVersionId), "Fetching version information"))!
const modrinthMod = (await output.withLoading(modrinthApi.getMod(modrinthVersion.projectId), "Fetching mod information"))!
await addModrinthMod(modrinthMod, modrinthVersion, content.side?.replace("both", "client-server"))
}
output.println(`${kleur.yellow(modFileName)} ${kleur.green("was imported.")} ${kleur.gray(`(${index}/${modFileNames.length})`)}`)
index++
}
output.println(`${kleur.yellow(modFileNames.length)} ${kleur.green("mods were imported.")}`)
})
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,42 @@
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, options) => {
const pack = await usePack()
const side: Side = options.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++
loader.setText(`Exporting ${kleur.yellow(metaFile.getDisplayString())} (${i}/${pack.metaFiles.length})`)
await writeAndIndexMetaFile(indexedFiles, outputDirectoryPath, metaFile)
}
i = 0
for (const staticSourceFile of pack.staticSourceFiles) {
i++
if (staticSourceFile.side !== "universal" && staticSourceFile.side !== side) continue
loader.setText(`Exporting ${kleur.yellow(staticSourceFile.relativePath.toString())} (${i}/${pack.metaFiles.length})`)
await writeAndIndexStaticSourceFile(indexedFiles, outputDirectoryPath, staticSourceFile)
}
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[]> { export async function fetchFabricMinecraftVersions(): Promise<string[]> {
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>() const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()

View file

@ -1,57 +1,54 @@
import { SafeParseError, z, ZodRawShape } from "zod" import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
import kleur from "kleur" import process from "process"
import fs from "fs-extra"
import * as process from "process"
import { dirname } from "path"
import { findUp } from "find-up" import { findUp } from "find-up"
import { output } from "./output.js" import { output } from "./utils/output.js"
import { Path } from "./path.js" import kleur from "kleur"
import { Dirent } from "fs" import { SafeParseError, z, ZodRawShape } from "zod"
import { sides } from "./shared.js" 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 if (process.argv0.endsWith("/node")) { // run using pnpm
return Path.createAbsolute("./test-pack") return envPaths.cwd.resolve("./test-pack")
} else { } else {
const parent = await findUp("horizr.json") const parent = await findUp(PACK_MANIFEST_FILE_NAME)
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`) 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>>( export async function writeJsonFile<S extends z.ZodObject<ZodRawShape>>(path: AbsolutePath, schema: S, data: z.input<S>) {
packPath: Path, await fs.mkdirp(path.parent().toString())
filePath: Path, await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 })
schema: S }
): Promise<z.output<S> | null> {
export async function readJsonFile<S extends z.ZodObject<ZodRawShape>>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise<z.output<S> | null> {
let data let data
try { try {
data = await fs.readJson(packPath.resolve(filePath).toString()) data = await fs.readJson(rootPath.resolve(specificPath).toString())
} catch (e: unknown) { } 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 else return null
} }
const result = await schema.safeParseAsync(data) const result = await schema.safeParseAsync(data)
if (!result.success) { if (!result.success) {
const error = (result as SafeParseError<unknown>).error 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 return result.data
} }
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) { export const PACK_MANIFEST_FORMAT_VERSION = 1
const absolutePath = packPath.resolve(filePath) export const PACK_MANIFEST_FILE_NAME = "horizr.json"
await fs.mkdirp(absolutePath.getParent().toString())
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 }) export const horizrFileSchema = z.object({
} formatVersion: z.literal(PACK_MANIFEST_FORMAT_VERSION),
const horizrFileSchema = z.object({
formatVersion: z.string().or(z.number()),
meta: z.object({ meta: z.object({
name: z.string(), name: z.string(),
version: z.string(), version: z.string(),
@ -65,75 +62,48 @@ const horizrFileSchema = z.object({
}) })
}) })
export type HorizrFile = z.output<typeof horizrFileSchema> export type PackManifest = z.output<typeof horizrFileSchema>
export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1
export async function readHorizrFile(packPath: Path) { export const META_FILE_EXTENSION = "hm.json"
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)}`)
return data const metaFileModrinthSourceSchema = z.object({
}
const modFileModrinthSourceSchema = z.object({
type: z.literal("modrinth"), type: z.literal("modrinth"),
modId: z.string(), modId: z.string(),
versionId: z.string() versionId: z.string()
}) })
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema> export type MetaFileModrinthSource = z.output<typeof metaFileModrinthSourceSchema>
const modFileDataSchema = z.object({ const metaFileContentVersionSchema = z.object({
version: z.string(),
name: z.string(), name: z.string(),
size: z.number().int().min(0).optional(), size: z.number().int().min(0).optional(),
fileName: z.string(),
downloadUrl: z.string().url(), downloadUrl: z.string().url(),
hashes: z.object({ // Adopted from Modrinth hashes: z.object({
sha1: z.string(), sha1: z.string(),
sha512: z.string() sha512: z.string()
}) })
}) })
export type ModFileData = z.output<typeof modFileDataSchema> export type MetaFileContentVersion = z.output<typeof metaFileContentVersionSchema>
const modFileSchema = z.object({ export const metaFileContentSchema = z.object({
name: z.string(), displayName: z.string().optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
ignoreUpdates: z.boolean().default(false),
side: z.enum(sides),
comment: z.string().optional(), comment: z.string().optional(),
file: modFileDataSchema, version: metaFileContentVersionSchema,
source: z.discriminatedUnion("type", [ source: z.discriminatedUnion("type", [
modFileModrinthSourceSchema, metaFileModrinthSourceSchema,
z.object({ type: z.literal("raw") }) 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> { export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), {
return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema) cwd: sourceDirectoryPath.toString(),
} followSymbolicLinks: false,
onlyFiles: true
export async function writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> { }).then(paths => paths.map(path => RelativePath.create(path)))
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 })
}

View file

@ -1,197 +1,34 @@
import { Command } from "commander" 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 { releaseChannelOrder } from "./shared.js"
import fs from "fs-extra" import fs from "fs-extra"
import { Path } from "./path.js" import { AbsolutePath } from "./utils/path.js"
import enquirer from "enquirer" import { output } from "./utils/output.js"
import { clearCache } from "./utils.js" import kleur from "kleur"
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js" 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"
const program = new Command("horizr") const program = new Command("horizr")
.version( .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" "-v, --version"
) )
.option("--clear-cache", "Clear the HTTP cache before doing the operation.") .option("--clear-cache", "Clear the HTTP cache before doing the operation.")
.on("option:clear-cache", () => { .on("option:clear-cache", () => {
clearCache() clearGotCache()
output.println(kleur.green("Cache was cleared.\n")) output.println(kleur.green("Cache was cleared.\n"))
}) })
.addCommand(modrinthCommand)
program.command("init <path>") .addCommand(packwizCommand)
.description("Initialize a new pack in the directory.") .addCommand(infoCommand)
.action(async path => { .addCommand(initCommand)
const directoryPath = Path.create(path) .addCommand(updateCommand)
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 enquirer.prompt({
type: "confirm",
name: "confirmed",
message: "Apply the update?"
}) as any).confirmed
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)}.`))
}
}
}
})
loudRejection(stack => { loudRejection(stack => {
output.failAndExit(stack) output.failAndExit(stack)
}) })
await program await program.parseAsync(process.argv)
.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)

View file

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

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

@ -0,0 +1,110 @@
import { IterableElement } from "type-fest"
import { modrinthApi, ModrinthMod, ModrinthVersion, ModrinthVersionFile } 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[], modrinthMod: ModrinthMod) =>
metaFiles.find(metaFile => metaFile.content.source?.type === "modrinth" && metaFile.content.source.modId === modrinthMod.id) ?? 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,91 +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, Side } 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, side?: Side) {
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`)}`
)
}
if (side === undefined) {
const isClientSupported = modrinthMod.clientSide !== "unsupported"
const isServerSupported = modrinthMod.serverSide !== "unsupported"
side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server"
}
await pack.addMod(id, {
name: modrinthMod.title,
enabled: true,
ignoreUpdates: false,
side,
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,162 @@
import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js" import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
import { output } from "./output.js" import {
import pLimit from "p-limit" findPackDirectoryPath,
PackManifest,
horizrFileSchema,
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 kleur from "kleur"
import { modrinthApi } from "./modrinth/api.js" import { orEmptyString } from "./utils/strings.js"
import semver from "semver" import fs from "fs-extra"
import { Path } from "./path.js"
import { ReleaseChannel, Side, sides } from "./shared.js" export type ReleaseChannel = "alpha" | "beta" | "release"
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js" 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 { export interface Update {
mod: Mod of: MetaFile
activeVersion: string versionString: string
availableVersion: string
changelog: string | null changelog: string | null
apply(): Promise<void> 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>
}
let pack: Pack let pack: Pack
export async function usePack(): Promise<Pack> { export async function usePack(): Promise<Pack> {
if (pack === undefined) { if (pack === undefined) {
const rootDirectoryPath = await findPackDirectoryPath() 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), horizrFileSchema))!
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,
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 = { pack = {
paths: { paths: {
root: rootDirectoryPath, root: rootDirectoryPath,
generated: rootDirectoryPath.resolve("generated"), source: sourceDirectoryPath,
mods: rootDirectoryPath.resolve("mods"), exports: rootDirectoryPath.resolve("exports")
overrides: {
client: overridesDirectoryPath.resolve("client"),
server: overridesDirectoryPath.resolve("server"),
"client-server": overridesDirectoryPath.resolve("client-server")
}
}, },
horizrFile: await readHorizrFile(rootDirectoryPath), manifest,
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => { metaFiles,
const mod: Mod = { staticSourceFiles,
id, readSourceJsonFile,
modFile: (await readModFile(rootDirectoryPath, id))!, registerCreatedSourceFile: registerSourceFile,
async saveModFile() { getMetaFile(relativePath: RelativePath) {
await writeModFile(rootDirectoryPath, id, this.modFile) 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)
}, },
findModByCode(code: string): Mod | null { getEffectiveMetaFile(effectivePath: RelativePath, side: Side) {
if (code.startsWith("mrv:")) { return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? null
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 { getMetaFileFromInput(input: string): MetaFile {
const mod = this.findModByCode(code) const path = envPaths.cwd.resolveAny(input)
if (mod === null) return output.failAndExit("The mod could not be found.") if (!path.isDescendantOf(sourceDirectoryPath)) output.failAndExit(`${kleur.yellow(pathModule.normalize(input))} is outside the source directory.`)
return mod
},
async validateOverridesDirectories() {
const dirents = await getOverrideDirents(overridesDirectoryPath)
const notDirectories = dirents.filter(dirent => !dirent.isDirectory()) const relativePath = sourceDirectoryPath.relativeTo(path.toString().endsWith("." + META_FILE_EXTENSION) ? path : (path.toString() + "." + META_FILE_EXTENSION))
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(", ")}`
)
if (dirents.some(dirent => !(sides as string[]).includes(dirent.name))) const metaFile = this.getMetaFile(relativePath)
output.failAndExit(`The ${kleur.yellow("overrides")} directory may only contain the following sub-directories:\n${sides.map(side => `- ${side}`).join("\n")}`) if (metaFile === null) return output.failAndExit(`${kleur.yellow(relativePath.toString())} does not exist.`)
},
async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]> {
const limit = pLimit(5)
const loader = output.startLoading(`Checking for updates (0/${this.mods.length})`) return metaFile
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[]
} }
} }
} }

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 effectiveOutputPath = metaFile.effectivePath
.parent()
.joinedWith(metaFile.effectivePath.getBasename().slice(0, -1 * META_FILE_EXTENSION.length) + "toml")
const outputPath = outputDirectoryPath.resolve(effectiveOutputPath)
await fs.mkdirp(outputPath.parent().toString())
await fs.writeFile(outputPath.toString(), content)
indexedFiles.push({
path: metaFile.effectivePath,
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,67 +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)
}
// Not tested
// isDescendantOf(other: Path) {
// if (!(this.isAbsolute() && other.isAbsolute())) throw new Error("Both paths must be absolute")
// return pathModule.relative(this.value, other.value).split("/").includes("..")
// }
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,128 +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"
import { dirname } from "path"
import { without } from "lodash-es"
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()))
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.relative(path).toString(), { compress: true })
} else return
emptyDirectories = without(emptyDirectories, dirname(path))
})
emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relative(p).toString()))
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
}

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(): string {
return pathModule.basename(this.pathString)
}
/**
* @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,8 +1,8 @@
import { Command } from "commander" import { Command } from "commander"
import { take } from "lodash-es" import { take } from "lodash-es"
import { usePack } from "../pack.js" import { usePack } from "../../pack.js"
import kleur from "kleur" import kleur from "kleur"
import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../utils.js" import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../../utils.js"
import { default as wrapAnsi } from "wrap-ansi" import { default as wrapAnsi } from "wrap-ansi"
import figures from "figures" import figures from "figures"
import { import {
@ -10,13 +10,13 @@ import {
ModrinthMod, ModrinthMod,
ModrinthVersion, ModrinthVersion,
ModrinthVersionRelation, ModrinthVersionRelation,
} from "../modrinth/api.js" } from "../../modrinth/api.js"
import dedent from "dedent" import dedent from "dedent"
import ago from "s-ago" import ago from "s-ago"
import semver from "semver" import semver from "semver"
import { output } from "../output.js" import { output } from "../../../src/utils/output.js"
import fs from "fs-extra" import fs from "fs-extra"
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js" import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../../modrinth/utils.js"
import { walk } from "@root/walk" import { walk } from "@root/walk"
const modrinthCommand = new Command("modrinth") const modrinthCommand = new Command("modrinth")

47
src_old/modrinth/utils.ts Normal file
View file

@ -0,0 +1,47 @@
import { IterableElement } from "type-fest"
import { sortBy } from "lodash-es"
import { Mod, Pack, usePack } from "../pack.js"
import { ModFile, ModFileData, MetaFileModrinthSource } from "../files.js"
import { pathExists } from "fs-extra"
import { nanoid } from "nanoid/non-secure"
import { output } from "../../src/utils/output.js"
import kleur from "kleur"
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
import { releaseChannelOrder, Side } from "../shared.js"
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, side?: Side) {
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`)}`
)
}
if (side === undefined) {
const isClientSupported = modrinthMod.clientSide !== "unsupported"
const isServerSupported = modrinthMod.serverSide !== "unsupported"
side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server"
}
await pack.addMod(id, {
name: modrinthMod.title,
enabled: true,
ignoreUpdates: false,
side,
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
source: {
type: "modrinth",
modId: modrinthMod.id,
versionId: modrinthVersion.id
}
})
}

View file

@ -0,0 +1,31 @@
hash-format = "sha512"
[[files]]
file = "mods/sodium.hm.json"
hash = "955b4e74bc9b1988cdbcfa659fb977997ebf99dbd701e4b2aa6175cacd4a763b39399ea5e16db5536cb59708e0f9ce84746c1611519c0689d640da6752b496e3"
metafile = true
[[files]]
file = "resourcepacks/better-leaves.hm.json"
hash = "81fc1a6887ad61e4ed5662d7173efd743280bd2ad640fdebd495b30ee915bdbe9b2882b55ca7dabac155ba1e4520ad3957f76cc1ac98dce0ef7087d3c07beed9"
metafile = true
[[files]]
file = "mods/charm.hm.json"
hash = "c388539aa188902f671e27de1bf8306ded8ed1ba7dc5cb3a9fd3b6f13895a789255d36c025b1ef2172c6123d99e28201660c83657d7d36a318c4387249c69931"
metafile = true
[[files]]
file = "mods/fabric-api.hm.json"
hash = "c9ca7daa8ed64738ddd1fa4d334e14e768623322e25f6f8eb38ac616bbef1b3823e55136b22ff7f1e88adec98b44b2ebdb6981ccff0c7e6f1acd4ca91f82c594"
metafile = true
[[files]]
file = "options.txt"
hash = "41ab8c11939b9379f97739cd998a44dae98f92fd3715253d14d979987a22e8ca7c5799396efc3951e16597346c590ae0bbbbf31dba9d7004c4459a3930322f20"
metafile = false
[[files]]
file = "config/charm.ini"
hash = "21ed919c05480f55d202dc97c33ae55d897acec162ac75d5f62d914ce5a18fbaac83dc631d4690896e3b6135da1305d0f3f73e1df3b54fbc777b186367ba421d"
metafile = false

View file

@ -0,0 +1,13 @@
name = "Charm"
filename = "charm-fabric-1.18.2-4.2.0.jar"
side = "both"
[download]
hash-format = "sha512"
hash = "3c8cd08ab1e37dcbf0f5a956cd20d84c98e58ab49fdc13faafb9c2af4dbf7fba7c8328cb5365997fe4414cfc5cb554ed13b3056a22df1c6bd335594f380facb6"
url = "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar"
[update]
[update.modrinth]
mod-id = "pOQTcQmj"
version = "BT9G1Jjs"

View file

@ -0,0 +1,13 @@
name = "Fabric API"
filename = "fabric-api-0.58.0+1.18.2.jar"
side = "both"
[download]
hash-format = "sha512"
hash = "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed"
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar"
[update]
[update.modrinth]
mod-id = "P7dR8mSH"
version = "4XRtXhtL"

View file

@ -0,0 +1,13 @@
name = "Sodium"
filename = "sodium-fabric-mc1.18.2-0.4.1+build.15.jar"
side = "client"
[download]
hash-format = "sha512"
hash = "86eb4db8fdb9f0bb06274c4f150b55273b5b770ffc89e0ba68011152a231b79ebe0b1adda0dd194f92cdcb386f7a60863d9fee5d15c1c3547ffa22a19083a1ee"
url = "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar"
[update]
[update.modrinth]
mod-id = "AANobbMI"
version = "74Y5Z8fo"

View file

@ -0,0 +1,13 @@
name = "Test"
author = "John Doe"
description = "A test pack for testing the horizr CLI. It is not intended for playing."
pack-format = "packwiz:1.1.0"
[versions]
minecraft = "1.18.2"
fabric = "0.14.7"
[index]
file = "index.toml"
hash-format = "sha512"
hash = "8173efc3a86743de3b35e88cb01fbe10f12b13ce4c99fd730abaeb81ee88d736078cc2dfcf45a12b36ded174c73ac59304f6074b2ec916babc5c941adc170299"

View file

@ -0,0 +1,8 @@
name = "better-leaves.hm.json"
filename = "Better-Leaves.zip"
side = "client"
[download]
hash-format = "sha512"
hash = "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817"
url = "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip"

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,9 @@
{ {
"name": "Sodium",
"enabled": true, "enabled": true,
"ignoreUpdates": false, "version": {
"side": "client", "name": "mc1.18.2-0.4.1",
"file": {
"version": "mc1.18.2-0.4.1",
"name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
"size": 1318645, "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", "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": { "hashes": {
"sha1": "f839863a6be7014b8d80058ea1f361521148d049", "sha1": "f839863a6be7014b8d80058ea1f361521148d049",
@ -16,6 +13,7 @@
"source": { "source": {
"type": "modrinth", "type": "modrinth",
"modId": "AANobbMI", "modId": "AANobbMI",
"versionId": "74Y5Z8fo" "versionId": "74Y5Z8fo",
"ignoreUpdates": false
} }
} }

View file

@ -0,0 +1 @@
option=1

View file

@ -0,0 +1,15 @@
{
"name": "Better Leaves",
"enabled": true,
"side": "client-server",
"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,19 @@
{
"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

@ -0,0 +1 @@
test=42

View file

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