I remembered I should commit every once in a while
This commit is contained in:
parent
fd7e154b5e
commit
585fd43708
52 changed files with 1386 additions and 1035 deletions
|
@ -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.
|
|
@ -24,6 +24,7 @@
|
|||
"dedent": "^0.7.0",
|
||||
"enquirer": "^2.3.6",
|
||||
"env-paths": "^3.0.0",
|
||||
"fast-glob": "^3.2.11",
|
||||
"figures": "^5.0.0",
|
||||
"find-up": "^6.3.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
|
|
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
@ -16,6 +16,7 @@ specifiers:
|
|||
del-cli: ^5.0.0
|
||||
enquirer: ^2.3.6
|
||||
env-paths: ^3.0.0
|
||||
fast-glob: ^3.2.11
|
||||
figures: ^5.0.0
|
||||
find-up: ^6.3.0
|
||||
fs-extra: ^10.1.0
|
||||
|
@ -47,6 +48,7 @@ dependencies:
|
|||
dedent: 0.7.0
|
||||
enquirer: 2.3.6
|
||||
env-paths: 3.0.0
|
||||
fast-glob: 3.2.11
|
||||
figures: 5.0.0
|
||||
find-up: 6.3.0
|
||||
fs-extra: 10.1.0
|
||||
|
@ -141,12 +143,10 @@ packages:
|
|||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
run-parallel: 1.2.0
|
||||
dev: true
|
||||
|
||||
/@nodelib/fs.stat/2.0.5:
|
||||
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||
engines: {node: '>= 8'}
|
||||
dev: true
|
||||
|
||||
/@nodelib/fs.walk/1.2.8:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
|
@ -154,7 +154,6 @@ packages:
|
|||
dependencies:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.13.0
|
||||
dev: true
|
||||
|
||||
/@root/walk/1.1.0:
|
||||
resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==}
|
||||
|
@ -323,7 +322,6 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
fill-range: 7.0.1
|
||||
dev: true
|
||||
|
||||
/buffer-crc32/0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
@ -801,7 +799,6 @@ packages:
|
|||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.5
|
||||
dev: true
|
||||
|
||||
/fast-url-parser/1.1.3:
|
||||
resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==}
|
||||
|
@ -813,7 +810,6 @@ packages:
|
|||
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
|
||||
dependencies:
|
||||
reusify: 1.0.4
|
||||
dev: true
|
||||
|
||||
/figures/5.0.0:
|
||||
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
||||
|
@ -828,7 +824,6 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
dev: true
|
||||
|
||||
/find-up/5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
|
@ -905,7 +900,6 @@ packages:
|
|||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
dev: true
|
||||
|
||||
/glob/7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
|
@ -1032,14 +1026,12 @@ packages:
|
|||
/is-extglob/2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/is-glob/4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
dev: true
|
||||
|
||||
/is-interactive/2.0.0:
|
||||
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
|
||||
|
@ -1049,7 +1041,6 @@ packages:
|
|||
/is-number/7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
dev: true
|
||||
|
||||
/is-path-cwd/3.0.0:
|
||||
resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==}
|
||||
|
@ -1214,7 +1205,6 @@ packages:
|
|||
/merge2/1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
dev: true
|
||||
|
||||
/micromatch/4.0.5:
|
||||
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
|
||||
|
@ -1222,7 +1212,6 @@ packages:
|
|||
dependencies:
|
||||
braces: 3.0.2
|
||||
picomatch: 2.3.1
|
||||
dev: true
|
||||
|
||||
/mime-db/1.33.0:
|
||||
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
|
||||
|
@ -1422,7 +1411,6 @@ packages:
|
|||
/picomatch/2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
dev: true
|
||||
|
||||
/pump/3.0.0:
|
||||
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
|
||||
|
@ -1437,7 +1425,6 @@ packages:
|
|||
|
||||
/queue-microtask/1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
dev: true
|
||||
|
||||
/quick-lru/5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
|
@ -1505,7 +1492,6 @@ packages:
|
|||
/reusify/1.0.4:
|
||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/rimraf/3.0.2:
|
||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
|
@ -1518,7 +1504,6 @@ packages:
|
|||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
dev: true
|
||||
|
||||
/s-ago/2.2.0:
|
||||
resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==}
|
||||
|
@ -1632,7 +1617,6 @@ packages:
|
|||
engines: {node: '>=8.0'}
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
dev: true
|
||||
|
||||
/toml/3.0.0:
|
||||
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
|
||||
|
|
24
src/commands/info.ts
Normal file
24
src/commands/info.ts
Normal 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
82
src/commands/init.ts
Normal 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))}`))
|
||||
})
|
82
src/commands/modrinth/activate.ts
Normal file
82
src/commands/modrinth/activate.ts
Normal 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())}`))
|
||||
})
|
16
src/commands/modrinth/index.ts
Normal file
16
src/commands/modrinth/index.ts
Normal 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)
|
||||
`)
|
|
@ -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}
|
42
src/commands/packwiz/export.ts
Normal file
42
src/commands/packwiz/export.ts
Normal 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"))
|
||||
})
|
22
src/commands/packwiz/import.ts
Normal file
22
src/commands/packwiz/import.ts
Normal 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
|
||||
})
|
10
src/commands/packwiz/index.ts
Normal file
10
src/commands/packwiz/index.ts
Normal 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)
|
24
src/commands/packwiz/serve.ts
Normal file
24
src/commands/packwiz/serve.ts
Normal 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
82
src/commands/update.ts
Normal 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)}.`))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
import { got } from "./utils.js"
|
||||
import { got } from "./utils/http.js"
|
||||
|
||||
export async function fetchFabricMinecraftVersions(): Promise<string[]> {
|
||||
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()
|
||||
|
|
126
src/files.ts
126
src/files.ts
|
@ -1,57 +1,54 @@
|
|||
import { SafeParseError, z, ZodRawShape } from "zod"
|
||||
import kleur from "kleur"
|
||||
import fs from "fs-extra"
|
||||
import * as process from "process"
|
||||
import { dirname } from "path"
|
||||
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||
import process from "process"
|
||||
import { findUp } from "find-up"
|
||||
import { output } from "./output.js"
|
||||
import { Path } from "./path.js"
|
||||
import { Dirent } from "fs"
|
||||
import { sides } from "./shared.js"
|
||||
import { output } from "./utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import { SafeParseError, z, ZodRawShape } from "zod"
|
||||
import fs from "fs-extra"
|
||||
import { dirname } from "path"
|
||||
import fastGlob from "fast-glob"
|
||||
import { sides } from "./pack.js"
|
||||
|
||||
export async function findPackDirectoryPath(): Promise<Path> {
|
||||
export async function findPackDirectoryPath(): Promise<AbsolutePath> {
|
||||
if (process.argv0.endsWith("/node")) { // run using pnpm
|
||||
return Path.createAbsolute("./test-pack")
|
||||
return envPaths.cwd.resolve("./test-pack")
|
||||
} else {
|
||||
const parent = await findUp("horizr.json")
|
||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`)
|
||||
const parent = await findUp(PACK_MANIFEST_FILE_NAME)
|
||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} could not be found in the current working directory or any parent.`)
|
||||
|
||||
return Path.createAbsolute(dirname(parent))
|
||||
return AbsolutePath.create(dirname(parent))
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
||||
packPath: Path,
|
||||
filePath: Path,
|
||||
schema: S
|
||||
): Promise<z.output<S> | null> {
|
||||
export async function writeJsonFile<S extends z.ZodObject<ZodRawShape>>(path: AbsolutePath, schema: S, data: z.input<S>) {
|
||||
await fs.mkdirp(path.parent().toString())
|
||||
await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 })
|
||||
}
|
||||
|
||||
export async function readJsonFile<S extends z.ZodObject<ZodRawShape>>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise<z.output<S> | null> {
|
||||
let data
|
||||
|
||||
try {
|
||||
data = await fs.readJson(packPath.resolve(filePath).toString())
|
||||
data = await fs.readJson(rootPath.resolve(specificPath).toString())
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`)
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(specificPath.toString())} does not contain valid JSON.`)
|
||||
else return null
|
||||
}
|
||||
|
||||
const result = await schema.safeParseAsync(data)
|
||||
if (!result.success) {
|
||||
const error = (result as SafeParseError<unknown>).error
|
||||
return output.failAndExit(`${kleur.yellow(filePath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||
return output.failAndExit(`${kleur.yellow(specificPath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) {
|
||||
const absolutePath = packPath.resolve(filePath)
|
||||
await fs.mkdirp(absolutePath.getParent().toString())
|
||||
export const PACK_MANIFEST_FORMAT_VERSION = 1
|
||||
export const PACK_MANIFEST_FILE_NAME = "horizr.json"
|
||||
|
||||
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
|
||||
}
|
||||
|
||||
const horizrFileSchema = z.object({
|
||||
formatVersion: z.string().or(z.number()),
|
||||
export const horizrFileSchema = z.object({
|
||||
formatVersion: z.literal(PACK_MANIFEST_FORMAT_VERSION),
|
||||
meta: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
|
@ -65,75 +62,48 @@ const horizrFileSchema = z.object({
|
|||
})
|
||||
})
|
||||
|
||||
export type HorizrFile = z.output<typeof horizrFileSchema>
|
||||
export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1
|
||||
export type PackManifest = z.output<typeof horizrFileSchema>
|
||||
|
||||
export async function readHorizrFile(packPath: Path) {
|
||||
const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema)
|
||||
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
||||
if (data.formatVersion !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
||||
export const META_FILE_EXTENSION = "hm.json"
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const modFileModrinthSourceSchema = z.object({
|
||||
const metaFileModrinthSourceSchema = z.object({
|
||||
type: z.literal("modrinth"),
|
||||
modId: z.string(),
|
||||
versionId: z.string()
|
||||
})
|
||||
|
||||
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema>
|
||||
export type MetaFileModrinthSource = z.output<typeof metaFileModrinthSourceSchema>
|
||||
|
||||
const modFileDataSchema = z.object({
|
||||
version: z.string(),
|
||||
const metaFileContentVersionSchema = z.object({
|
||||
name: z.string(),
|
||||
size: z.number().int().min(0).optional(),
|
||||
fileName: z.string(),
|
||||
downloadUrl: z.string().url(),
|
||||
hashes: z.object({ // Adopted from Modrinth
|
||||
hashes: z.object({
|
||||
sha1: z.string(),
|
||||
sha512: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export type ModFileData = z.output<typeof modFileDataSchema>
|
||||
export type MetaFileContentVersion = z.output<typeof metaFileContentVersionSchema>
|
||||
|
||||
const modFileSchema = z.object({
|
||||
name: z.string(),
|
||||
export const metaFileContentSchema = z.object({
|
||||
displayName: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
ignoreUpdates: z.boolean().default(false),
|
||||
side: z.enum(sides),
|
||||
comment: z.string().optional(),
|
||||
file: modFileDataSchema,
|
||||
version: metaFileContentVersionSchema,
|
||||
source: z.discriminatedUnion("type", [
|
||||
modFileModrinthSourceSchema,
|
||||
metaFileModrinthSourceSchema,
|
||||
z.object({ type: z.literal("raw") })
|
||||
])
|
||||
]).and(z.object({
|
||||
ignoreUpdates: z.boolean().default(false)
|
||||
})).optional()
|
||||
})
|
||||
|
||||
export type ModFile = z.output<typeof modFileSchema>
|
||||
export type MetaFileContent = z.output<typeof metaFileContentSchema>
|
||||
|
||||
export async function readModFile(packPath: Path, modId: string): Promise<ModFile | null> {
|
||||
return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema)
|
||||
}
|
||||
|
||||
export async function writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
||||
await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data)
|
||||
}
|
||||
|
||||
export async function removeModFile(packPath: Path, modId: string): Promise<void> {
|
||||
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
|
||||
}
|
||||
|
||||
export async function readModIds(packPath: Path) {
|
||||
const modsPath = packPath.resolve("mods")
|
||||
await fs.mkdirp(modsPath.toString())
|
||||
const files = await fs.readdir(modsPath.toString(), { withFileTypes: true })
|
||||
|
||||
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
||||
}
|
||||
|
||||
export async function getOverrideDirents(overridesDirectoryPath: Path): Promise<Dirent[]> {
|
||||
if (!await fs.pathExists(overridesDirectoryPath.toString())) return []
|
||||
|
||||
return await fs.readdir(overridesDirectoryPath.toString(), { withFileTypes: true })
|
||||
}
|
||||
export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), {
|
||||
cwd: sourceDirectoryPath.toString(),
|
||||
followSymbolicLinks: false,
|
||||
onlyFiles: true
|
||||
}).then(paths => paths.map(path => RelativePath.create(path)))
|
||||
|
|
199
src/main.ts
199
src/main.ts
|
@ -1,197 +1,34 @@
|
|||
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 { Path } from "./path.js"
|
||||
import enquirer from "enquirer"
|
||||
import { clearCache } from "./utils.js"
|
||||
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js"
|
||||
import { AbsolutePath } from "./utils/path.js"
|
||||
import { output } from "./utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import loudRejection from "loud-rejection"
|
||||
import { clearGotCache } from "./utils/http.js"
|
||||
import { initCommand } from "./commands/init.js"
|
||||
import { infoCommand } from "./commands/info.js"
|
||||
import { updateCommand } from "./commands/update.js"
|
||||
import { packwizCommand } from "./commands/packwiz/index.js"
|
||||
import { modrinthCommand } from "./commands/modrinth/index.js"
|
||||
|
||||
const program = new Command("horizr")
|
||||
.version(
|
||||
(await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version,
|
||||
(await fs.readJson(AbsolutePath.create(import.meta.url.slice(5)).parent().resolve("../package.json").toString())).version,
|
||||
"-v, --version"
|
||||
)
|
||||
.option("--clear-cache", "Clear the HTTP cache before doing the operation.")
|
||||
.on("option:clear-cache", () => {
|
||||
clearCache()
|
||||
clearGotCache()
|
||||
output.println(kleur.green("Cache was cleared.\n"))
|
||||
})
|
||||
|
||||
program.command("init <path>")
|
||||
.description("Initialize a new pack in the directory.")
|
||||
.action(async path => {
|
||||
const directoryPath = Path.create(path)
|
||||
const horizrFilePath = directoryPath.resolve("horizr.json")
|
||||
|
||||
if (await fs.pathExists(horizrFilePath.toString())) output.failAndExit(`${kleur.yellow("horizr.json")} already exists in the directory.`)
|
||||
|
||||
await fs.mkdirp(directoryPath.toString())
|
||||
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
|
||||
|
||||
const answers: any = await enquirer.prompt([
|
||||
{
|
||||
name: "name",
|
||||
type: "input",
|
||||
message: "Name",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "authors",
|
||||
type: "input",
|
||||
message: "Authors (comma-separated)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
message: "Description"
|
||||
},
|
||||
{
|
||||
name: "license",
|
||||
type: "text",
|
||||
message: "License (SPDX-ID)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "minecraftVersion",
|
||||
type: "autocomplete",
|
||||
message: "Minecraft version",
|
||||
choices: minecraftVersions.map(version => ({
|
||||
name: version,
|
||||
value: version
|
||||
})),
|
||||
// @ts-expect-error
|
||||
limit: 10,
|
||||
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
|
||||
}
|
||||
])
|
||||
|
||||
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
|
||||
|
||||
const file: HorizrFile = {
|
||||
formatVersion: CURRENT_HORIZR_FILE_FORMAT_VERSION,
|
||||
meta: {
|
||||
name: answers.name,
|
||||
version: "1.0.0",
|
||||
description: answers.description === "" ? undefined : answers.description,
|
||||
authors: (answers.authors as string).split(", ").map(a => a.trim()),
|
||||
license: answers.license
|
||||
},
|
||||
versions: {
|
||||
minecraft: answers.minecraftVersion,
|
||||
fabric: fabricVersion
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeJson(horizrFilePath.toString(), file, { spaces: 2 })
|
||||
await fs.writeFile(directoryPath.resolve(".gitignore").toString(), "/generated/")
|
||||
|
||||
const relativePath = Path.create(process.cwd()).relative(directoryPath).toString()
|
||||
if (relativePath === "") output.println(kleur.green(`Successfully initialized pack.`))
|
||||
else output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(relativePath)}.`))
|
||||
})
|
||||
|
||||
program.command("info", { isDefault: true })
|
||||
.description("Print information about the pack.")
|
||||
.action(async () => {
|
||||
const pack = await usePack()
|
||||
const disabledModsCount = pack.mods.filter(mod => !mod.modFile.enabled).length
|
||||
const { description } = pack.horizrFile.meta
|
||||
|
||||
output.println(dedent`
|
||||
${kleur.underline(pack.horizrFile.meta.name)} ${kleur.dim(`(${pack.horizrFile.meta.version})`)}
|
||||
${description === undefined ? "" : wrapAnsi(description, process.stdout.columns) + "\n"}\
|
||||
|
||||
Authors: ${kleur.yellow(pack.horizrFile.meta.authors.join(", "))}
|
||||
License: ${kleur.yellow(pack.horizrFile.meta.license.toUpperCase())}
|
||||
Mods: ${kleur.yellow(pack.mods.length.toString())}${disabledModsCount === 0 ? "" : ` (${disabledModsCount} disabled)`}
|
||||
|
||||
Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)}
|
||||
`)
|
||||
})
|
||||
|
||||
program.command("remove <code>")
|
||||
.description("Remove the mod from the pack.")
|
||||
.action(async code => {
|
||||
const pack = await usePack()
|
||||
const mod = pack.findModByCodeOrFail(code)
|
||||
|
||||
await removeModFile(pack.paths.root, mod.id)
|
||||
|
||||
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
||||
})
|
||||
|
||||
program.command("update [code]")
|
||||
.description("Check for updates of all mods or update a specific mod")
|
||||
.option("-y, --yes", "Skip confirmations")
|
||||
.option("-b, --allow-beta", "Allow beta versions")
|
||||
.option("-a, --allow-alpha", "Allow alpha and beta versions")
|
||||
.action(async (code, options) => {
|
||||
const pack = await usePack()
|
||||
const allowedReleaseChannels = releaseChannelOrder.slice(releaseChannelOrder.indexOf(options.allowAlpha ? "alpha" : options.allowBeta ? "beta" : "release"))
|
||||
|
||||
if (code === undefined) {
|
||||
const updates = await pack.checkForUpdates(allowedReleaseChannels)
|
||||
|
||||
if (updates.length === 0) output.println(kleur.green("Everything up-to-date."))
|
||||
else {
|
||||
output.println(dedent`
|
||||
${kleur.underline("Available updates")}
|
||||
|
||||
${updates.map(update => `- ${kleur.gray(update.mod.id)} ${update.mod.modFile.name}: ${kleur.red(update.activeVersion)} ${figures.arrowRight} ${kleur.green(update.availableVersion)}`).join("\n")}
|
||||
`)
|
||||
}
|
||||
} else {
|
||||
const mod = pack.findModByCodeOrFail(code)
|
||||
const update = await output.withLoading(mod.checkForUpdate(allowedReleaseChannels), "Checking for an update")
|
||||
|
||||
if (update === null) {
|
||||
output.println(kleur.green("No update available."))
|
||||
} else {
|
||||
if (update.changelog === null) {
|
||||
output.println(`No changelog available for ${kleur.bold(update.availableVersion)}.`)
|
||||
} else {
|
||||
output.println(`${kleur.underline("Changelog")} for ${kleur.bold().yellow(update.availableVersion)}\n`)
|
||||
output.printlnWrapping(update.changelog)
|
||||
}
|
||||
|
||||
output.println("")
|
||||
|
||||
const confirmed = options.yes || (await 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)}.`))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCommand(modrinthCommand)
|
||||
.addCommand(packwizCommand)
|
||||
.addCommand(infoCommand)
|
||||
.addCommand(initCommand)
|
||||
.addCommand(updateCommand)
|
||||
|
||||
loudRejection(stack => {
|
||||
output.failAndExit(stack)
|
||||
})
|
||||
|
||||
await program
|
||||
.addCommand(packwizCommand)
|
||||
.addCommand(modrinthCommand)
|
||||
.addHelpText("after", "\n" + dedent`
|
||||
${kleur.blue("code")} can be one of the following:
|
||||
- The name of a file in the ${kleur.yellow("mods")} directory, optionally without the ${kleur.yellow(".json")} extension
|
||||
- The ID of a Modrinth Project, prefixed with ${kleur.yellow("mr:")}
|
||||
- The ID of a Modrinth Version, prefixed with ${kleur.yellow("mrv:")}
|
||||
`)
|
||||
.parseAsync(process.argv)
|
||||
await program.parseAsync(process.argv)
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import { HTTPError, Response } from "got"
|
||||
import { output } from "../utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import { delay, got } from "../utils.js"
|
||||
import { output } from "../output.js"
|
||||
import { dependencyToRelatedVersionType } from "./utils.js"
|
||||
import { ReleaseChannel } from "../shared.js"
|
||||
import { got } from "../utils/http.js"
|
||||
import { delay } from "../utils/promises.js"
|
||||
import { dependencyToRelatedVersionType } from "./index.js"
|
||||
import { ReleaseChannel } from "../pack.js"
|
||||
import { orEmptyString } from "../utils/strings.js"
|
||||
|
||||
const BASE_URL = "https://api.modrinth.com"
|
||||
|
||||
async function getModrinthApiOptional(url: string): Promise<any | null> {
|
||||
let response: Response
|
||||
|
||||
while (true) {
|
||||
response = await got(url, {
|
||||
prefixUrl: "https://api.modrinth.com",
|
||||
prefixUrl: BASE_URL,
|
||||
throwHttpErrors: false,
|
||||
retry: {
|
||||
limit: 3,
|
||||
|
@ -56,7 +60,7 @@ async function getModrinthApiOptional(url: string): Promise<any | null> {
|
|||
|
||||
async function getModrinthApi(url: string): Promise<any> {
|
||||
const response = await getModrinthApiOptional(url)
|
||||
if (response === null) return output.failAndExit("Request failed with status code 404.")
|
||||
if (response === null) return output.failAndExit(`Request failed with status code 404: ${kleur.yellow(BASE_URL + "/" + url)}`)
|
||||
return response
|
||||
}
|
||||
|
||||
|
@ -188,8 +192,8 @@ export const modrinthApi = {
|
|||
updateDate: new Date(response.updated)
|
||||
}
|
||||
},
|
||||
async listVersions(idOrSlug: string, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`)
|
||||
async listVersions(idOrSlug: string, minecraftVersion?: string): Promise<ModrinthVersion[]> {
|
||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]${orEmptyString(minecraftVersion, v => `&game_versions=["${v}"]`)}`)
|
||||
|
||||
return response.map(transformApiModVersion)
|
||||
},
|
||||
|
|
110
src/modrinth/index.ts
Normal file
110
src/modrinth/index.ts
Normal 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
56
src/modrinth/updating.ts
Normal 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()
|
||||
}
|
||||
}))
|
||||
}
|
|
@ -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]
|
||||
}
|
287
src/pack.ts
287
src/pack.ts
|
@ -1,173 +1,162 @@
|
|||
import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
|
||||
import { output } from "./output.js"
|
||||
import pLimit from "p-limit"
|
||||
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||
import {
|
||||
findPackDirectoryPath,
|
||||
PackManifest,
|
||||
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 { modrinthApi } from "./modrinth/api.js"
|
||||
import semver from "semver"
|
||||
import { Path } from "./path.js"
|
||||
import { ReleaseChannel, Side, sides } from "./shared.js"
|
||||
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
|
||||
import { orEmptyString } from "./utils/strings.js"
|
||||
import fs from "fs-extra"
|
||||
|
||||
export type ReleaseChannel = "alpha" | "beta" | "release"
|
||||
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
||||
|
||||
export type Side = "client" | "server" | "universal"
|
||||
export const sides: [Side, ...Side[]] = ["client", "server", "universal"]
|
||||
|
||||
export interface Pack {
|
||||
manifest: PackManifest
|
||||
|
||||
paths: {
|
||||
root: AbsolutePath
|
||||
source: AbsolutePath
|
||||
exports: AbsolutePath
|
||||
}
|
||||
|
||||
metaFiles: MetaFile[]
|
||||
staticSourceFiles: StaticSourceFile[]
|
||||
getMetaFile(path: RelativePath): MetaFile | null
|
||||
getMetaFileFromInput(input: string): MetaFile
|
||||
getEffectiveMetaFile(path: RelativePath, side: Side): MetaFile | null
|
||||
registerCreatedSourceFile(path: RelativePath): Promise<void>
|
||||
|
||||
readSourceJsonFile<S extends z.ZodObject<ZodRawShape>>(path: RelativePath, schema: S): Promise<z.output<S> | null>
|
||||
}
|
||||
|
||||
export interface SourceFile {
|
||||
isStatic: boolean
|
||||
isMod: boolean
|
||||
side: Side
|
||||
relativePath: RelativePath
|
||||
absolutePath: AbsolutePath
|
||||
effectivePath: RelativePath
|
||||
}
|
||||
|
||||
export interface MetaFile extends SourceFile {
|
||||
isStatic: false
|
||||
content: MetaFileContent
|
||||
fetchUpdates: null | ((allowedReleaseChannels: ReleaseChannel[]) => Promise<Update[]>)
|
||||
saveContent(): Promise<void>
|
||||
|
||||
getDisplayString(): string
|
||||
}
|
||||
|
||||
export interface StaticSourceFile extends SourceFile {
|
||||
isStatic: true
|
||||
}
|
||||
|
||||
export interface Update {
|
||||
mod: Mod
|
||||
activeVersion: string
|
||||
availableVersion: string
|
||||
of: MetaFile
|
||||
versionString: string
|
||||
changelog: string | null
|
||||
apply(): Promise<void>
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
paths: {
|
||||
root: Path,
|
||||
mods: Path,
|
||||
generated: Path,
|
||||
overrides: Record<Side, Path>
|
||||
},
|
||||
horizrFile: HorizrFile
|
||||
mods: Mod[]
|
||||
|
||||
addMod(id: string, file: ModFile): Promise<void>
|
||||
findModByCode(code: string): Mod | null
|
||||
findModByCodeOrFail(code: string): Mod
|
||||
validateOverridesDirectories(): Promise<void>
|
||||
|
||||
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
|
||||
}
|
||||
|
||||
export interface Mod {
|
||||
id: string
|
||||
|
||||
modFile: ModFile
|
||||
saveModFile(): Promise<void>
|
||||
|
||||
checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null>
|
||||
}
|
||||
|
||||
let pack: Pack
|
||||
|
||||
export async function usePack(): Promise<Pack> {
|
||||
if (pack === undefined) {
|
||||
const rootDirectoryPath = await findPackDirectoryPath()
|
||||
const overridesDirectoryPath = rootDirectoryPath.resolve("overrides")
|
||||
const sourceDirectoryPath = rootDirectoryPath.resolve("src")
|
||||
const readSourceJsonFile: Pack["readSourceJsonFile"] = async (path, schema) => readJsonFile(sourceDirectoryPath, path, schema)
|
||||
|
||||
const manifest = (await readJsonFile(rootDirectoryPath, RelativePath.create(PACK_MANIFEST_FILE_NAME), 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 = {
|
||||
paths: {
|
||||
root: rootDirectoryPath,
|
||||
generated: rootDirectoryPath.resolve("generated"),
|
||||
mods: rootDirectoryPath.resolve("mods"),
|
||||
overrides: {
|
||||
client: overridesDirectoryPath.resolve("client"),
|
||||
server: overridesDirectoryPath.resolve("server"),
|
||||
"client-server": overridesDirectoryPath.resolve("client-server")
|
||||
}
|
||||
source: sourceDirectoryPath,
|
||||
exports: rootDirectoryPath.resolve("exports")
|
||||
},
|
||||
horizrFile: await readHorizrFile(rootDirectoryPath),
|
||||
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
|
||||
const mod: Mod = {
|
||||
id,
|
||||
modFile: (await readModFile(rootDirectoryPath, id))!,
|
||||
async saveModFile() {
|
||||
await writeModFile(rootDirectoryPath, id, this.modFile)
|
||||
manifest,
|
||||
metaFiles,
|
||||
staticSourceFiles,
|
||||
readSourceJsonFile,
|
||||
registerCreatedSourceFile: registerSourceFile,
|
||||
getMetaFile(relativePath: RelativePath) {
|
||||
return metaFiles.find(metaFile => metaFile.relativePath.is(relativePath)) ?? null
|
||||
},
|
||||
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
|
||||
if (mod.modFile.ignoreUpdates) return null
|
||||
|
||||
if (mod.modFile.source.type === "modrinth") {
|
||||
const activeVersionString = mod.modFile.file.version
|
||||
const activeSemver = semver.parse(activeVersionString)
|
||||
if (activeSemver === null) output.warn(
|
||||
`${kleur.yellow(mod.modFile.name)} has no valid semantic version: ${kleur.yellow(mod.modFile.file.version)}. ` +
|
||||
`The publication date will instead be used.`
|
||||
)
|
||||
|
||||
const versions = await modrinthApi.listVersions(mod.modFile.source.modId, pack.horizrFile.versions.minecraft)
|
||||
const allowedVersions = versions.filter(version => allowedReleaseChannels.includes(version.releaseChannel))
|
||||
|
||||
const newerVersions = activeSemver === null ? allowedVersions : allowedVersions.filter(version => {
|
||||
const thisSemver = semver.parse(version.versionString)
|
||||
if (thisSemver === null) return false
|
||||
|
||||
return thisSemver.compare(activeSemver) === 1
|
||||
})
|
||||
|
||||
if (newerVersions.length === 0) return null
|
||||
|
||||
const sortedNewerVersions = sortModrinthVersionsByPreference(newerVersions)
|
||||
const newestVersion = sortedNewerVersions[0]
|
||||
|
||||
if (activeSemver === null ? activeVersionString === newestVersion.versionString : semver.eq(activeSemver, newestVersion.versionString)) return null
|
||||
|
||||
return {
|
||||
mod,
|
||||
activeVersion: activeVersionString,
|
||||
availableVersion: newestVersion.versionString,
|
||||
changelog: newestVersion.changelog,
|
||||
async apply() {
|
||||
const modrinthMod = (await modrinthApi.getMod(newestVersion.projectId))!
|
||||
|
||||
mod.modFile.file = getModFileDataForModrinthVersion(modrinthMod, newestVersion)
|
||||
;(mod.modFile.source as ModFileModrinthSource).versionId = newestVersion.id
|
||||
|
||||
await mod.saveModFile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.warn(`${kleur.yellow(mod.modFile.name)} has no source information attached.`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return mod
|
||||
})),
|
||||
async addMod(id: string, file: ModFile) {
|
||||
await writeModFile(rootDirectoryPath, id, file)
|
||||
getEffectiveMetaFile(effectivePath: RelativePath, side: Side) {
|
||||
return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? null
|
||||
},
|
||||
findModByCode(code: string): Mod | null {
|
||||
if (code.startsWith("mrv:")) {
|
||||
return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.versionId === code.slice(4)) ?? null
|
||||
} else if (code.startsWith("mr:")) {
|
||||
return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === code.slice(3)) ?? null
|
||||
} else if (code.endsWith(".json")) {
|
||||
return this.mods.find(mod => mod.id === code.slice(0, -5)) ?? null
|
||||
} else {
|
||||
return this.mods.find(mod => mod.id === code) ?? null
|
||||
}
|
||||
},
|
||||
findModByCodeOrFail(code: string): Mod {
|
||||
const mod = this.findModByCode(code)
|
||||
if (mod === null) return output.failAndExit("The mod could not be found.")
|
||||
return mod
|
||||
},
|
||||
async validateOverridesDirectories() {
|
||||
const dirents = await getOverrideDirents(overridesDirectoryPath)
|
||||
getMetaFileFromInput(input: string): MetaFile {
|
||||
const path = envPaths.cwd.resolveAny(input)
|
||||
if (!path.isDescendantOf(sourceDirectoryPath)) output.failAndExit(`${kleur.yellow(pathModule.normalize(input))} is outside the source directory.`)
|
||||
|
||||
const notDirectories = dirents.filter(dirent => !dirent.isDirectory())
|
||||
if (notDirectories.length !== 0)
|
||||
output.failAndExit(
|
||||
`The ${kleur.yellow("overrides")} directory contains files that are not directories:\n${notDirectories.slice(0, 5).map(e => `- ${e.name}`).join("\n")}` +
|
||||
(notDirectories.length > 5 ? `\n${kleur.gray(`and ${notDirectories.length - 5} more`)}` : "") +
|
||||
`\n\nAll files must reside in one of these sub-directories: ${sides.map(kleur.yellow).join(", ")}`
|
||||
)
|
||||
const relativePath = sourceDirectoryPath.relativeTo(path.toString().endsWith("." + META_FILE_EXTENSION) ? path : (path.toString() + "." + META_FILE_EXTENSION))
|
||||
|
||||
if (dirents.some(dirent => !(sides as string[]).includes(dirent.name)))
|
||||
output.failAndExit(`The ${kleur.yellow("overrides")} directory may only contain the following sub-directories:\n${sides.map(side => `- ${side}`).join("\n")}`)
|
||||
},
|
||||
async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]> {
|
||||
const limit = pLimit(5)
|
||||
const metaFile = this.getMetaFile(relativePath)
|
||||
if (metaFile === null) return output.failAndExit(`${kleur.yellow(relativePath.toString())} does not exist.`)
|
||||
|
||||
const loader = output.startLoading(`Checking for updates (0/${this.mods.length})`)
|
||||
let finishedCount = 0
|
||||
const updates: Array<Update | null> = await Promise.all(this.mods.map(mod => limit(async () => {
|
||||
const update = await mod.checkForUpdate(allowedReleaseChannels)
|
||||
finishedCount++
|
||||
loader.setText(`Checking for updates (${finishedCount}/${this.mods.length})`)
|
||||
return update
|
||||
})))
|
||||
|
||||
loader.stop()
|
||||
return updates.filter(info => info !== null) as Update[]
|
||||
return metaFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
104
src/packwiz/exporting.ts
Normal file
104
src/packwiz/exporting.ts
Normal 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}"
|
||||
`)
|
||||
}
|
67
src/path.ts
67
src/path.ts
|
@ -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)
|
||||
}
|
|
@ -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"]
|
128
src/utils.ts
128
src/utils.ts
|
@ -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
16
src/utils/collections.ts
Normal 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
69
src/utils/http.ts
Normal 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
10
src/utils/misc.ts
Normal 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
29
src/utils/options.ts
Normal 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
131
src/utils/path.ts
Normal 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
26
src/utils/promises.ts
Normal 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
9
src/utils/strings.ts
Normal 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
32
src/utils/zip.ts
Normal 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")
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { Command } from "commander"
|
||||
import { take } from "lodash-es"
|
||||
import { usePack } from "../pack.js"
|
||||
import { usePack } from "../../pack.js"
|
||||
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 figures from "figures"
|
||||
import {
|
||||
|
@ -10,13 +10,13 @@ import {
|
|||
ModrinthMod,
|
||||
ModrinthVersion,
|
||||
ModrinthVersionRelation,
|
||||
} from "../modrinth/api.js"
|
||||
} from "../../modrinth/api.js"
|
||||
import dedent from "dedent"
|
||||
import ago from "s-ago"
|
||||
import semver from "semver"
|
||||
import { output } from "../output.js"
|
||||
import { output } from "../../../src/utils/output.js"
|
||||
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"
|
||||
|
||||
const modrinthCommand = new Command("modrinth")
|
47
src_old/modrinth/utils.ts
Normal file
47
src_old/modrinth/utils.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
31
test-pack/exports/packwiz/index.toml
Normal file
31
test-pack/exports/packwiz/index.toml
Normal 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
|
13
test-pack/exports/packwiz/mods/charm.toml
Normal file
13
test-pack/exports/packwiz/mods/charm.toml
Normal 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"
|
13
test-pack/exports/packwiz/mods/fabric-api.toml
Normal file
13
test-pack/exports/packwiz/mods/fabric-api.toml
Normal 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"
|
13
test-pack/exports/packwiz/mods/sodium.toml
Normal file
13
test-pack/exports/packwiz/mods/sodium.toml
Normal 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"
|
13
test-pack/exports/packwiz/pack.toml
Normal file
13
test-pack/exports/packwiz/pack.toml
Normal 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"
|
|
@ -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"
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -1,12 +1,9 @@
|
|||
{
|
||||
"name": "Sodium",
|
||||
"enabled": true,
|
||||
"ignoreUpdates": false,
|
||||
"side": "client",
|
||||
"file": {
|
||||
"version": "mc1.18.2-0.4.1",
|
||||
"name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
||||
"version": {
|
||||
"name": "mc1.18.2-0.4.1",
|
||||
"size": 1318645,
|
||||
"fileName": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
||||
"downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar",
|
||||
"hashes": {
|
||||
"sha1": "f839863a6be7014b8d80058ea1f361521148d049",
|
||||
|
@ -16,6 +13,7 @@
|
|||
"source": {
|
||||
"type": "modrinth",
|
||||
"modId": "AANobbMI",
|
||||
"versionId": "74Y5Z8fo"
|
||||
"versionId": "74Y5Z8fo",
|
||||
"ignoreUpdates": false
|
||||
}
|
||||
}
|
1
test-pack/src/client/options.txt
Normal file
1
test-pack/src/client/options.txt
Normal file
|
@ -0,0 +1 @@
|
|||
option=1
|
15
test-pack/src/client/resourcepacks/better-leaves.hm.json
Normal file
15
test-pack/src/client/resourcepacks/better-leaves.hm.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
19
test-pack/src/server/mods/lithium.hm.json
Normal file
19
test-pack/src/server/mods/lithium.hm.json
Normal 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
|
||||
}
|
||||
}
|
1
test-pack/src/universal/config/charm.ini
Normal file
1
test-pack/src/universal/config/charm.ini
Normal file
|
@ -0,0 +1 @@
|
|||
test=42
|
|
@ -1,12 +1,10 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"displayName": "Charm",
|
||||
"enabled": true,
|
||||
"ignoreUpdates": false,
|
||||
"side": "client-server",
|
||||
"file": {
|
||||
"version": "4.2.0+1.18.2",
|
||||
"name": "charm-fabric-1.18.2-4.2.0.jar",
|
||||
"version": {
|
||||
"name": "4.2.0+1.18.2",
|
||||
"size": 3413876,
|
||||
"fileName": "charm-fabric-1.18.2-4.2.0.jar",
|
||||
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
|
||||
"hashes": {
|
||||
"sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82",
|
||||
|
@ -16,6 +14,7 @@
|
|||
"source": {
|
||||
"type": "modrinth",
|
||||
"modId": "pOQTcQmj",
|
||||
"versionId": "BT9G1Jjs"
|
||||
"versionId": "BT9G1Jjs",
|
||||
"ignoreUpdates": false
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue