Compare commits
16 commits
Author | SHA1 | Date | |
---|---|---|---|
1fa972ab5a | |||
aa0703966a | |||
152386bcbf | |||
d12d483f64 | |||
e9788879fb | |||
6a4a7b35f5 | |||
ce7867ec60 | |||
6f3d5bf54c | |||
2d1f3156c9 | |||
d40c3b82e3 | |||
5a1c6f47e4 | |||
585fd43708 | |||
fd7e154b5e | |||
b8e60da181 | |||
8e7d5d1d55 | |||
d5d00c674b |
53 changed files with 1932 additions and 1790 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
node_modules/
|
||||
.idea/
|
||||
dist/
|
||||
/node_modules/
|
||||
/.idea/
|
||||
/dist/
|
||||
/schemas/
|
||||
|
|
84
README.md
84
README.md
|
@ -1,14 +1,12 @@
|
|||

|
||||
|
||||
# horizr CLI
|
||||

|
||||
|
||||
> A CLI tool for creating and maintaining Minecraft modpacks using the Fabric loader.
|
||||
|
||||
🎉 Features:
|
||||
- Access [Modrinth](https://modrinth.com/)
|
||||
- Search
|
||||
- Add
|
||||
- View available versions
|
||||
- View dependencies
|
||||
- Add mods from [Modrinth](https://modrinth.com/)
|
||||
- Check for updates and view changelogs before applying them
|
||||
- Export the pack to the [Modrinth format (`.mrpack`)](https://docs.modrinth.com/docs/modpacks/format_definition/)
|
||||
- Export the pack to the [`packwiz`](https://packwiz.infra.link/) format
|
||||
|
@ -17,7 +15,7 @@
|
|||
## Usage
|
||||
|
||||
Because both [pkg](https://github.com/vercel/pkg) and [nexe](https://github.com/nexe/nexe) don’t support ES modules at the time of writing,
|
||||
I can‘t publish executable files.
|
||||
I can’t publish executable files.
|
||||
|
||||
The only way of installing is therefore `npm`.
|
||||
|
||||
|
@ -27,70 +25,16 @@ $ npm i -g @horizr/cli
|
|||
|
||||
Run any command with the `-h` flag to see the available options.
|
||||
|
||||
A new pack can be initiated using `horizr init <path>`.
|
||||
A new pack can be initialized using `horizr init <path>`.
|
||||
|
||||
## Examples
|
||||
## Contributing
|
||||
I developed this tool primarily for my own packs, that’s why its missing some features I didn’t absolutely need.
|
||||
|
||||
- Activate the latest (compatible) version of [Charm](https://modrinth.com/mod/charm)
|
||||
```sh
|
||||
$ horizr modrinth mod activate charm
|
||||
Nevertheless, if you want a feature added, feel free to [create an issue](https://github.com/horizr/cli/issues/new).
|
||||
A pull request would be even better.
|
||||
|
||||
# or short:
|
||||
$ horizr mr mod a charm
|
||||
```
|
||||
|
||||
- Activate `v4.1.1` of [Charm](https://modrinth.com/mod/charm)
|
||||
```sh
|
||||
$ horizr modrinth mod versions charm
|
||||
|
||||
# `BT9G1Jjs` is the version code you are looking for.
|
||||
# This output will be colored in your console.
|
||||
BT9G1Jjs 4.2.0+1.18.2 (↓ 137)
|
||||
featured
|
||||
|
||||
Name: [1.18.2] 4.2.0
|
||||
Channel: release
|
||||
Minecraft versions: 1.18.2
|
||||
|
||||
Publication: last week
|
||||
|
||||
https://modrinth.com/mod/pOQTcQmj/version/BT9G1Jjs
|
||||
|
||||
# … more versions omitted for brevity
|
||||
|
||||
$ horizr modrinth version activate BT9G1Jjs
|
||||
|
||||
Charm (4.2.0+1.18.2) was successfully activated.
|
||||
|
||||
|
||||
Dependencies
|
||||
◉ Fabric API (P7dR8mSH): any version
|
||||
|
||||
```
|
||||
|
||||
- Check for updates
|
||||
```sh
|
||||
$ horizr update
|
||||
# Because Sodium's version string is not a valid SemVer,
|
||||
# the publication date will instead be used for comparison.
|
||||
❯ Sodium has no valid semantic version: mc1.18.2-0.4.1. The
|
||||
publication date will instead be used.
|
||||
|
||||
Available updates
|
||||
- charm Charm: 4.1.0+1.18.2 → 4.2.0+1.18.2
|
||||
```
|
||||
|
||||
```sh
|
||||
$ horizr update charm
|
||||
|
||||
Changelog for 4.2.0+1.18.2
|
||||
|
||||
* Added ebony wood.
|
||||
* Fixed issue with Totems not always spawning or being
|
||||
carried away by mobs.
|
||||
# … omitted for brevity
|
||||
|
||||
Apply the update? [Y/n] y
|
||||
|
||||
Successfully updated Charm to 4.2.0+1.18.2.
|
||||
```
|
||||
Features I have in mind:
|
||||
- List disabled source files
|
||||
- Allow disabling static source files by adding `.disabled` to their name
|
||||
- Import packwiz packs
|
||||
- Hot-reloading `packwiz dev` command
|
||||
|
|
BIN
banner.png
Normal file
BIN
banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -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.
|
15
package.json
15
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@horizr/cli",
|
||||
"version": "1.1.0",
|
||||
"version": "2.0.1",
|
||||
"main": "./dist/main.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
@ -8,10 +8,11 @@
|
|||
"repository": "https://github.com/horizr/cli",
|
||||
"scripts": {
|
||||
"start": "tsx src/main.ts",
|
||||
"build": "del dist && tsc"
|
||||
"build": "del dist && tsc",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"bin": {
|
||||
"horizr": "bin/horizr.js"
|
||||
"horizr": "./bin/horizr.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
@ -19,11 +20,13 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"@root/walk": "^1.1.0",
|
||||
"@sindresorhus/slugify": "^2.1.0",
|
||||
"address": "^1.2.0",
|
||||
"commander": "^9.4.0",
|
||||
"dedent": "^0.7.0",
|
||||
"enquirer": "^2.3.6",
|
||||
"env-paths": "^3.0.0",
|
||||
"fast-glob": "^3.2.11",
|
||||
"figures": "^5.0.0",
|
||||
"find-up": "^6.3.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
|
@ -34,16 +37,18 @@
|
|||
"lodash-es": "^4.17.21",
|
||||
"loud-rejection": "^2.2.0",
|
||||
"nanoid": "^4.0.0",
|
||||
"open": "^8.4.0",
|
||||
"ora": "^6.1.2",
|
||||
"p-event": "^5.0.1",
|
||||
"p-limit": "^4.0.0",
|
||||
"s-ago": "^2.2.0",
|
||||
"semver": "^7.3.7",
|
||||
"serve-handler": "^6.1.3",
|
||||
"toml": "^3.0.0",
|
||||
"wrap-ansi": "^8.0.1",
|
||||
"yazl": "^2.5.1",
|
||||
"yesno": "^0.4.0",
|
||||
"zod": "^3.18.0"
|
||||
"zod": "^3.18.0",
|
||||
"zod-to-json-schema": "^3.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dedent": "^0.7.0",
|
||||
|
|
817
pnpm-lock.yaml
generated
817
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
24
src/commands/exportJsonSchemas.ts
Normal file
24
src/commands/exportJsonSchemas.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Command } from "commander"
|
||||
import fs from "fs-extra"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { metaFileContentSchema, packManifestFileSchema } from "../files.js"
|
||||
|
||||
export const exportJsonSchemasCommand = new Command("export-json-schemas")
|
||||
.argument("<path>")
|
||||
.description("Exports the pack manifest and meta-file JSON schemas.")
|
||||
.action(async path => {
|
||||
await fs.mkdirp(path)
|
||||
await fs.writeJson(path + "/manifest.schema.json", {
|
||||
title: "Horizr pack manifest",
|
||||
$id: "https://horizr.moritzruth.de/schemas/pack/manifest.schema.json",
|
||||
...zodToJsonSchema(packManifestFileSchema),
|
||||
$schema: "https://json-schema.org/draft-07/schema" // HTTPS
|
||||
}, { spaces: 2 })
|
||||
|
||||
await fs.writeJson(path + "/meta-file.schema.json", {
|
||||
title: "Horizr pack meta-file",
|
||||
$id: "https://horizr.moritzruth.de/schemas/pack/meta-file.schema.json",
|
||||
...zodToJsonSchema(metaFileContentSchema),
|
||||
$schema: "https://json-schema.org/draft-07/schema" // HTTPS
|
||||
}, { spaces: 2 })
|
||||
})
|
24
src/commands/info.ts
Normal file
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)}
|
||||
`)
|
||||
})
|
85
src/commands/init.ts
Normal file
85
src/commands/init.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { Command } from "commander"
|
||||
import { envPaths } from "../utils/path.js"
|
||||
import fs from "fs-extra"
|
||||
import { output } from "../utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "../fabricApi.js"
|
||||
import enquirer from "enquirer"
|
||||
import { PACK_MANIFEST_FILE_NAME, FORMAT_VERSION, PackManifest } from "../files.js"
|
||||
import pathModule from "path"
|
||||
import { EXPORTS_DIRECTORY_NAME } from "../pack.js"
|
||||
import slugify from "@sindresorhus/slugify"
|
||||
|
||||
export const initCommand = new Command("init")
|
||||
.argument("<path>")
|
||||
.description("Initialize a new pack in the directory.")
|
||||
.action(async pathString => {
|
||||
const path = envPaths.cwd.resolveAny(pathString)
|
||||
const manifestFilePath = path.resolve(PACK_MANIFEST_FILE_NAME)
|
||||
|
||||
if (await fs.pathExists(manifestFilePath.toString())) output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} already exists in the directory.`)
|
||||
|
||||
await fs.mkdirp(path.toString())
|
||||
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
|
||||
|
||||
const answers: any = await enquirer.prompt([
|
||||
{
|
||||
name: "name",
|
||||
type: "input",
|
||||
message: "Name",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "authors",
|
||||
type: "input",
|
||||
message: "Authors (comma-separated)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
message: "Description"
|
||||
},
|
||||
{
|
||||
name: "license",
|
||||
type: "text",
|
||||
message: "License (SPDX-ID)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "minecraftVersion",
|
||||
type: "autocomplete",
|
||||
message: "Minecraft version",
|
||||
choices: minecraftVersions.map(version => ({
|
||||
name: version,
|
||||
value: version
|
||||
})),
|
||||
// @ts-expect-error
|
||||
limit: 10,
|
||||
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
|
||||
}
|
||||
])
|
||||
|
||||
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
|
||||
|
||||
const file: PackManifest = {
|
||||
formatVersion: FORMAT_VERSION,
|
||||
slug: slugify(answers.name),
|
||||
meta: {
|
||||
name: answers.name,
|
||||
version: "1.0.0",
|
||||
description: answers.description === "" ? undefined : answers.description,
|
||||
authors: (answers.authors as string).split(", ").map(a => a.trim()),
|
||||
license: answers.license
|
||||
},
|
||||
versions: {
|
||||
minecraft: answers.minecraftVersion,
|
||||
fabric: fabricVersion
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeJson(manifestFilePath.toString(), file, { spaces: 2 })
|
||||
await fs.writeFile(path.resolve(".gitignore").toString(), `/${EXPORTS_DIRECTORY_NAME}/`)
|
||||
|
||||
output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(pathModule.normalize(pathString))}`))
|
||||
})
|
|
@ -1,382 +0,0 @@
|
|||
import { Command } from "commander"
|
||||
import { take } from "lodash-es"
|
||||
import { usePack } from "../pack.js"
|
||||
import kleur from "kleur"
|
||||
import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../utils.js"
|
||||
import { default as wrapAnsi } from "wrap-ansi"
|
||||
import figures from "figures"
|
||||
import {
|
||||
modrinthApi,
|
||||
ModrinthMod,
|
||||
ModrinthVersion,
|
||||
ModrinthVersionRelation,
|
||||
} from "../modrinth/api.js"
|
||||
import dedent from "dedent"
|
||||
import ago from "s-ago"
|
||||
import semver from "semver"
|
||||
import { output } from "../output.js"
|
||||
import fs from "fs-extra"
|
||||
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js"
|
||||
|
||||
const modrinthCommand = new Command("modrinth")
|
||||
.alias("mr")
|
||||
|
||||
modrinthCommand.command("search <query...>")
|
||||
.description("Search for mods.")
|
||||
.option("-l, --limit <number>", "Limit the number of results", optionParsePositiveInteger, 8)
|
||||
.option("-s, --skip <number>", "Skip results", optionParsePositiveInteger, 0)
|
||||
.action(async (query, options) => {
|
||||
const pack = await usePack()
|
||||
const loader = output.startLoading(`Searching for ${kleur.yellow(query)}`)
|
||||
const { results } = await modrinthApi.searchMods(pack.horizrFile.versions.minecraft, query, options)
|
||||
loader.stop()
|
||||
|
||||
output.println(
|
||||
results.map(result =>
|
||||
`${kleur.blue(result.id)} ${kleur.bold(truncateWithEllipsis(result.title, 30))} ${kleur.gray(`(↓ ${result.downloadsCount})`)}\n` +
|
||||
wrapAnsi(result.description, process.stdout.columns)
|
||||
)
|
||||
.join("\n\n")
|
||||
)
|
||||
})
|
||||
|
||||
const colorBySideCompatibility: Record<ModrinthMod["clientSide"], kleur.Color> = {
|
||||
optional: kleur.blue,
|
||||
required: kleur.green,
|
||||
unsupported: kleur.red
|
||||
}
|
||||
|
||||
const modrinthModCommand = modrinthCommand.command("mod")
|
||||
|
||||
modrinthModCommand.command("info <id>")
|
||||
.description("Show information about the mod.")
|
||||
.action(async id => {
|
||||
const loader = output.startLoading("Fetching mod information")
|
||||
const modrinthMod = await modrinthApi.getMod(id)
|
||||
if (modrinthMod === null) return loader.failAndExit("not found")
|
||||
|
||||
loader.stop()
|
||||
const existingMod = await findModForModrinthMod(modrinthMod)
|
||||
|
||||
output.println(dedent`
|
||||
${kleur.bold(modrinthMod.title)} ${kleur.gray(`(↓ ${modrinthMod.downloadsCount})`)}
|
||||
${wrapAnsi(modrinthMod.description, process.stdout.columns)}
|
||||
|
||||
Client Server
|
||||
${colorBySideCompatibility[modrinthMod.clientSide](modrinthMod.clientSide.padEnd(12, " "))} ${colorBySideCompatibility[modrinthMod.serverSide](modrinthMod.serverSide)}
|
||||
|
||||
License: ${kleur.yellow(modrinthMod.licenseCode.toUpperCase())}
|
||||
Last update: ${kleur.yellow(ago(modrinthMod.updateDate))}\
|
||||
${existingMod === null ? "" : kleur.green("\n\nThis mod is in the pack.")}
|
||||
|
||||
https://modrinth.com/mod/${modrinthMod.slug}
|
||||
`)
|
||||
})
|
||||
|
||||
modrinthModCommand.command("versions <id>")
|
||||
.description("Show a list of compatible versions of the mod.")
|
||||
.option("-l, --limit <number>", "Limit the number of versions displayed.", optionParsePositiveInteger, 3)
|
||||
.action(async (id, options) => {
|
||||
const pack = await usePack()
|
||||
|
||||
const loader = output.startLoading("Fetching mod information")
|
||||
const modrinthMod = await modrinthApi.getMod(id)
|
||||
if (modrinthMod === null) return loader.failAndExit("not found")
|
||||
else loader.stop()
|
||||
|
||||
const existingMod = await findModForModrinthMod(modrinthMod)
|
||||
const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions")
|
||||
|
||||
if (modrinthVersions.length === 0) {
|
||||
const message =
|
||||
`There are no versions compatible with the pack (Fabric ${kleur.yellow(pack.horizrFile.versions.fabric)}, Minecraft ${kleur.yellow(pack.horizrFile.versions.minecraft)}).`
|
||||
|
||||
output.println(kleur.red(message))
|
||||
} else {
|
||||
const versions = take(sortModrinthVersionsByPreference(modrinthVersions), options.limit)
|
||||
.map(modrinthVersion => {
|
||||
const state = existingMod !== null && existingMod.modFile.source.versionId === modrinthVersion.id
|
||||
? kleur.bgGreen().black(" active ") + "\n\n"
|
||||
: modrinthVersion.isFeatured
|
||||
? kleur.green("featured") + "\n\n"
|
||||
: ""
|
||||
|
||||
return dedent`
|
||||
${kleur.blue(modrinthVersion.id)} ${kleur.bold(modrinthVersion.versionString)} ${kleur.gray(`(↓ ${modrinthVersion.downloadsCount})`)}
|
||||
${state}\
|
||||
${modrinthVersion.name !== modrinthVersion.versionString ? `Name: ${kleur.yellow(modrinthVersion.name)}\n` : ""}\
|
||||
Channel: ${kleur.yellow(modrinthVersion.releaseChannel)}
|
||||
Minecraft versions: ${kleur.yellow(modrinthVersion.supportedMinecraftVersions.join(", "))}
|
||||
|
||||
Publication: ${kleur.yellow(ago(modrinthVersion.publicationDate))}
|
||||
|
||||
https://modrinth.com/mod/${modrinthVersion.projectId}/version/${modrinthVersion.id}
|
||||
`
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
output.println(versions)
|
||||
}
|
||||
})
|
||||
|
||||
modrinthModCommand.command("activate <id>")
|
||||
.description("Activate the recommended version of the mod.")
|
||||
.alias("a")
|
||||
.option("-f, --force", "Replace a different version already active.")
|
||||
.action(async (id, options) => {
|
||||
const pack = await usePack()
|
||||
|
||||
const loader = output.startLoading("Fetching mod information")
|
||||
const modrinthMod = await modrinthApi.getMod(id)
|
||||
if (modrinthMod === null) return loader.failAndExit("not found")
|
||||
else loader.stop()
|
||||
|
||||
const modrinthVersions = await output.withLoading(modrinthApi.listVersions(id, pack.horizrFile.versions.minecraft), "Fetching versions")
|
||||
if (modrinthVersions.length === 0) return output.failAndExit("There is no compatible version of this mod.")
|
||||
|
||||
const sortedModrinthVersions = sortModrinthVersionsByPreference(modrinthVersions)
|
||||
const modrinthVersion = sortedModrinthVersions[0]
|
||||
|
||||
await handleActivate(modrinthMod, modrinthVersion, options.force)
|
||||
})
|
||||
|
||||
const colorByRelationType: Record<ModrinthVersionRelation["type"], kleur.Color> = {
|
||||
"embedded_dependency": kleur.green,
|
||||
"soft_dependency": kleur.magenta,
|
||||
"hard_dependency": kleur.yellow,
|
||||
"incompatible": kleur.red
|
||||
}
|
||||
|
||||
const nullVersionStringByRelationType: Record<ModrinthVersionRelation["type"], string> = {
|
||||
"embedded_dependency": "unknown version",
|
||||
"soft_dependency": "any version",
|
||||
"hard_dependency": "any version",
|
||||
"incompatible": "all versions"
|
||||
}
|
||||
|
||||
const versionStateStrings = {
|
||||
"active": kleur.bgGreen().black(" active "),
|
||||
"compatible": kleur.blue("compatible"),
|
||||
"incompatible": kleur.red("incompatible"),
|
||||
"newer_version": `${kleur.bgYellow().black(" older version active ")} ${figures.arrowRight} EXISTING_VERSION`,
|
||||
"older_version": `${kleur.bgYellow().black(" newer version active ")} ${figures.arrowRight} EXISTING_VERSION`,
|
||||
"different_version": `${kleur.bgYellow().black(" different version active ")} ${figures.arrowRight} EXISTING_VERSION`
|
||||
}
|
||||
|
||||
async function getRelationsListLines(relations: ModrinthVersionRelation[]) {
|
||||
return await Promise.all(relations.map(async relation => {
|
||||
const color = colorByRelationType[relation.type]
|
||||
|
||||
const relatedVersion = relation.versionId === null ? null : (await modrinthApi.getVersion(relation.versionId))
|
||||
const versionString = relatedVersion === null ? nullVersionStringByRelationType[relation.type] : relatedVersion.versionString
|
||||
const relatedMod = (await modrinthApi.getMod(relation.projectId === null ? relatedVersion!.projectId : relation.projectId))!
|
||||
|
||||
return `${color(figures.circleFilled)} ${relatedMod.title}${relation.projectId ? ` (${kleur.blue(relation.projectId)})` : ""}: ` +
|
||||
`${versionString}${relation.versionId ? ` (${kleur.blue(relation.versionId)})` + " " : ""}`
|
||||
}))
|
||||
}
|
||||
|
||||
const modrinthVersionCommand = modrinthCommand.command("version")
|
||||
|
||||
modrinthVersionCommand.command("info <id>")
|
||||
.description("Show information about the version.")
|
||||
.option("-c, --changelog", "Show the changelog.")
|
||||
.action(async (id, options) => {
|
||||
const pack = await usePack()
|
||||
const loader = output.startLoading("Fetching version information")
|
||||
|
||||
const modrinthVersion = await modrinthApi.getVersion(id)
|
||||
if (modrinthVersion === null) return loader.failAndExit("not found")
|
||||
|
||||
loader.setText("Fetching mod information")
|
||||
const modrinthMod = (await modrinthApi.getMod(modrinthVersion.projectId))!
|
||||
|
||||
const existingMod = await findModForModrinthMod(modrinthMod)
|
||||
|
||||
let state: keyof typeof versionStateStrings
|
||||
if (existingMod === null) state = isModrinthVersionCompatible(modrinthVersion, pack) ? "compatible" : "incompatible"
|
||||
else {
|
||||
if (existingMod.modFile.source.versionId === modrinthVersion.id) state = "active"
|
||||
else {
|
||||
const existingSemver = semver.parse(existingMod.modFile.file.version)
|
||||
const newSemver = semver.parse(modrinthVersion.versionString)
|
||||
|
||||
if (existingSemver === null || newSemver === null) state = "different_version"
|
||||
else {
|
||||
const comparison = newSemver.compare(existingSemver)
|
||||
|
||||
if (comparison === 1) state = "newer_version"
|
||||
else if (comparison === -1) state = "older_version"
|
||||
else state = "active" // this should not happen: the versionString is the same but the versionId is different
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loader.setText("Resolving relations")
|
||||
|
||||
const relationsList = modrinthVersion.relations.length !== 0 ? (await getRelationsListLines(modrinthVersion.relations)).join("\n") : kleur.gray("none")
|
||||
|
||||
const relationsColorKey = `${colorByRelationType.hard_dependency("hard dependency")}, ${colorByRelationType.soft_dependency("soft dependency")}, ` +
|
||||
`${colorByRelationType.embedded_dependency("embedded")}, ${colorByRelationType.incompatible("incompatible")}`
|
||||
|
||||
loader.stop()
|
||||
|
||||
output.println(dedent`
|
||||
${kleur.underline(modrinthMod.title)} ${kleur.yellow(`${modrinthVersion.versionString} (${modrinthVersion.releaseChannel})`)}
|
||||
${versionStateStrings[state].replace("EXISTING_VERSION", existingMod?.modFile?.file.version ?? "ERROR")}
|
||||
|
||||
Version name: ${kleur.yellow(modrinthVersion.name)} ${kleur.gray(ago(modrinthVersion.publicationDate))}
|
||||
Minecraft versions: ${modrinthVersion.supportedMinecraftVersions.map(version => version === pack.horizrFile.versions.minecraft ? kleur.green(version) : kleur.red(version)).join(", ")}
|
||||
Loaders: ${modrinthVersion.supportedLoaders.map(loader => loader === "fabric" ? kleur.green(loader) : kleur.red(loader)).join(", ")}
|
||||
|
||||
Related mods: ${relationsColorKey}
|
||||
${relationsList}
|
||||
|
||||
https://modrinth.com/mod/${modrinthMod.slug}/version/${modrinthVersion.versionString}
|
||||
`)
|
||||
|
||||
if (options.changelog) {
|
||||
output.println("")
|
||||
output.println(kleur.underline("Changelog"))
|
||||
if (modrinthVersion.changelog === null) output.println(kleur.gray("not available"))
|
||||
else output.printlnWrapping(modrinthVersion.changelog)
|
||||
}
|
||||
})
|
||||
|
||||
modrinthVersionCommand.command("activate <id>")
|
||||
.description("Activate the mod version.")
|
||||
.alias("a")
|
||||
.option("-f, --force", "Replace a different version already active.")
|
||||
.action(async (id, options) => {
|
||||
const pack = await usePack()
|
||||
const loader = output.startLoading("Fetching version information")
|
||||
|
||||
const modrinthVersion = await modrinthApi.getVersion(id)
|
||||
if (modrinthVersion === null) return loader.failAndExit("not found")
|
||||
|
||||
loader.setText("Fetching mod information")
|
||||
const modrinthMod = (await modrinthApi.getMod(modrinthVersion.projectId))!
|
||||
loader.stop()
|
||||
|
||||
if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.")
|
||||
|
||||
await handleActivate(modrinthMod, modrinthVersion, options.force)
|
||||
})
|
||||
|
||||
modrinthCommand.command("export")
|
||||
.description("Export a Modrinth pack.")
|
||||
.option("-s, --no-generate", "Skip regenerating the output directory.")
|
||||
.option("-z, --no-zip", "Skip creating a zipped .mrpack file.")
|
||||
.option("-c, --clear", "Remove the output directory afterwards.")
|
||||
.action(async options => {
|
||||
const pack = await usePack()
|
||||
|
||||
const outputDirectory = pack.paths.generated.resolve("modrinth-pack")
|
||||
|
||||
if (options.generate) {
|
||||
const loader = output.startLoading("Generating")
|
||||
await pack.validateOverridesDirectories()
|
||||
await fs.remove(outputDirectory.toString())
|
||||
await fs.mkdirp(outputDirectory.toString())
|
||||
|
||||
await fs.writeJson(outputDirectory.resolve("modrinth.index.json").toString(), {
|
||||
formatVersion: 1,
|
||||
game: "minecraft",
|
||||
versionId: pack.horizrFile.meta.version,
|
||||
name: pack.horizrFile.meta.name,
|
||||
summary: pack.horizrFile.meta.description,
|
||||
dependencies: {
|
||||
minecraft: pack.horizrFile.versions.minecraft,
|
||||
"fabric-loader": pack.horizrFile.versions.fabric
|
||||
},
|
||||
files: pack.mods.map(mod => ({
|
||||
path: `mods/${mod.modFile.file.name}`,
|
||||
hashes: {
|
||||
sha1: mod.modFile.file.hashes.sha1,
|
||||
sha512: mod.modFile.file.hashes.sha512
|
||||
},
|
||||
env: {
|
||||
client: mod.modFile.side === "client" || mod.modFile.side === "client-server" ? "required" : "unsupported",
|
||||
server: mod.modFile.side === "server" || mod.modFile.side === "client-server" ? "required" : "unsupported"
|
||||
},
|
||||
downloads: [
|
||||
mod.modFile.file.downloadUrl
|
||||
],
|
||||
fileSize: mod.modFile.file.size
|
||||
}))
|
||||
}, { spaces: 2 })
|
||||
|
||||
if (await fs.pathExists(pack.paths.overrides["client-server"].toString())) await output.withLoading(
|
||||
fs.copy(pack.paths.overrides["client-server"].toString(), outputDirectory.resolve("overrides").toString(), { recursive: true }),
|
||||
"Copying client-server overrides"
|
||||
)
|
||||
|
||||
if (await fs.pathExists(pack.paths.overrides["client"].toString())) await output.withLoading(
|
||||
fs.copy(pack.paths.overrides["client"].toString(), outputDirectory.resolve("client-overrides").toString(), { recursive: true }),
|
||||
"Copying client overrides"
|
||||
)
|
||||
|
||||
if (await fs.pathExists(pack.paths.overrides["server"].toString())) await output.withLoading(
|
||||
fs.copy(pack.paths.overrides["server"].toString(), outputDirectory.resolve("server-overrides").toString(), { recursive: true }),
|
||||
"Copying server overrides"
|
||||
)
|
||||
|
||||
output.println(kleur.green(`Generated Modrinth pack`))
|
||||
loader.stop()
|
||||
}
|
||||
|
||||
if (options.zip) {
|
||||
if (!(await fs.pathExists(outputDirectory.toString())))
|
||||
output.failAndExit(`The ${kleur.yellow("modrinth-pack")} directory does not exist.\nRun the command without ${kleur.yellow("--no-generate")} to create it.`)
|
||||
|
||||
await output.withLoading(zipDirectory(outputDirectory, pack.paths.generated.resolve("pack.mrpack")), `Creating ${kleur.yellow(".mrpack")} file`)
|
||||
output.println(kleur.green(`Created ${kleur.yellow("pack.mrpack")}`))
|
||||
}
|
||||
|
||||
if (options.clear) {
|
||||
await fs.remove(outputDirectory.toString())
|
||||
output.println(kleur.green(`Removed the ${kleur.yellow("modrinth-pack")} directory`))
|
||||
}
|
||||
})
|
||||
|
||||
async function handleActivate(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, force: boolean) {
|
||||
const existingMod = await findModForModrinthMod(modrinthMod)
|
||||
|
||||
if (existingMod === null) {
|
||||
await addModrinthMod(modrinthMod, modrinthVersion)
|
||||
output.println(`${modrinthMod.title} (${modrinthVersion.versionString}) ${kleur.green("was successfully activated.")}\n`)
|
||||
|
||||
await handleDependencies(modrinthVersion.relations)
|
||||
} else {
|
||||
const oldVersion = existingMod.modFile.file.version
|
||||
if (existingMod.modFile.source.versionId === modrinthVersion.id) {
|
||||
output.println(kleur.green("This version is already installed."))
|
||||
} else if (force) {
|
||||
existingMod.modFile.file = getModFileDataForModrinthVersion(modrinthMod, modrinthVersion)
|
||||
existingMod.modFile.source.versionId = modrinthVersion.id
|
||||
await existingMod.saveModFile()
|
||||
output.println(`${kleur.green("Successfully replaced version")} ${oldVersion} ${kleur.green("of")} ${modrinthMod.title} ${kleur.green("with")} ${modrinthVersion.versionString}${kleur.green(".")}`)
|
||||
|
||||
await handleDependencies(modrinthVersion.relations)
|
||||
} else {
|
||||
output.failAndExit(`There is already a different version of this mod installed.\nRun this command again with ${kleur.yellow("-f")} to change the version.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDependencies(relations: ModrinthVersionRelation[]) {
|
||||
const loader = output.startLoading("Fetching dependency information")
|
||||
const lines = await getRelationsListLines(relations.filter(relation => relation.type === "hard_dependency" || relation.type === "soft_dependency"))
|
||||
|
||||
if (lines.length !== 0) {
|
||||
output.println(dedent`
|
||||
\n${kleur.underline("Dependencies")} ${colorByRelationType.hard_dependency("hard")}, ${colorByRelationType.soft_dependency("soft")}
|
||||
|
||||
${lines.join("\n")}
|
||||
`)
|
||||
}
|
||||
|
||||
loader.stop()
|
||||
}
|
||||
|
||||
export { modrinthCommand }
|
101
src/commands/modrinth/activate.ts
Normal file
101
src/commands/modrinth/activate.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { Command } from "commander"
|
||||
import { sideOption } from "../../utils/options.js"
|
||||
import {
|
||||
findMetaFileForModrinthMod,
|
||||
getMetaFileContentVersionForModrinth,
|
||||
getSideOfModrinthMod,
|
||||
isModrinthVersionCompatible, resolveFullRelation,
|
||||
resolveModrinthCode,
|
||||
sortModrinthVersionsByPreference
|
||||
} from "../../modrinth/index.js"
|
||||
import { output } from "../../utils/output.js"
|
||||
import { modrinthApi } from "../../modrinth/api.js"
|
||||
import { Side, usePack } from "../../pack.js"
|
||||
import kleur from "kleur"
|
||||
import { FORMAT_VERSION, META_FILE_EXTENSION, metaFileContentSchema, writeJsonFile } from "../../files.js"
|
||||
import fs from "fs-extra"
|
||||
import enquirer from "enquirer"
|
||||
import { orEmptyString } from "../../utils/strings.js"
|
||||
|
||||
export const activateCommand = new Command("activate")
|
||||
.argument("<code>")
|
||||
.alias("a")
|
||||
.option("-s, --side <side>", "The side of the mod", sideOption, null)
|
||||
.option("-y, --yes", "Skip confirmations.")
|
||||
.action(async (code, options) => {
|
||||
const pack = await usePack()
|
||||
const resolvedCode = await output.withLoading(resolveModrinthCode(code), "Resolving code")
|
||||
const modrinthMod = resolvedCode.modrinthMod
|
||||
let modrinthVersion = resolvedCode.modrinthVersion
|
||||
|
||||
const existingMetaFile = findMetaFileForModrinthMod(pack.metaFiles, modrinthMod.id)
|
||||
if (existingMetaFile !== null) {
|
||||
output.println(`The mod is already active: ${kleur.yellow(existingMetaFile.relativePath.toString())} ${kleur.blue(existingMetaFile.content.version.name)}`)
|
||||
|
||||
const confirmed = options.yes || (await enquirer.prompt({
|
||||
type: "confirm",
|
||||
name: "confirmed",
|
||||
message: "Do you want to continue?",
|
||||
initial: false
|
||||
}) as any).confirmed
|
||||
|
||||
if (!confirmed) process.exit()
|
||||
}
|
||||
|
||||
let side: Side
|
||||
const specifiedSide = getSideOfModrinthMod(modrinthMod)
|
||||
const sideOverride = options.side
|
||||
|
||||
if (sideOverride === null) side = specifiedSide
|
||||
else {
|
||||
if (specifiedSide !== "universal" && specifiedSide !== sideOverride) return output.failAndExit(`Mod is incompatible with specified side: ${kleur.yellow(sideOverride)}`)
|
||||
else side = sideOverride
|
||||
}
|
||||
|
||||
if (modrinthVersion === null) {
|
||||
const versions = await output.withLoading(modrinthApi.listVersions(modrinthMod.id, pack.manifest.versions.minecraft), "Fetching versions")
|
||||
if (versions.length === 0) return output.failAndExit("No compatible version available.")
|
||||
|
||||
const sortedVersions = sortModrinthVersionsByPreference(versions)
|
||||
modrinthVersion = sortedVersions[0]
|
||||
} else {
|
||||
if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.")
|
||||
}
|
||||
|
||||
const absolutePath = pack.paths.source.resolve(side, "mods", `${modrinthMod.slug}.${META_FILE_EXTENSION}`)
|
||||
const relativePath = pack.paths.source.relativeTo(absolutePath)
|
||||
|
||||
await fs.mkdirp(absolutePath.parent().toString())
|
||||
await writeJsonFile(absolutePath, metaFileContentSchema, {
|
||||
displayName: modrinthMod.title,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
enabled: true,
|
||||
version: getMetaFileContentVersionForModrinth(modrinthVersion),
|
||||
source: {
|
||||
type: "modrinth",
|
||||
versionId: modrinthVersion.id,
|
||||
modId: modrinthVersion.projectId,
|
||||
ignoreUpdates: false
|
||||
}
|
||||
})
|
||||
|
||||
await pack.registerCreatedSourceFile(relativePath)
|
||||
output.println(kleur.green(`Successfully wrote ${kleur.yellow(relativePath.toString())}`))
|
||||
|
||||
const loader = output.startLoading("Checking dependencies")
|
||||
|
||||
for (const relation of modrinthVersion.relations) {
|
||||
if (relation.type === "hard_dependency") {
|
||||
const { modrinthMod, modrinthVersion } = await resolveFullRelation(relation)
|
||||
|
||||
const metaFile = await findMetaFileForModrinthMod(pack.metaFiles, modrinthMod.id)
|
||||
if (metaFile === null) {
|
||||
const versionString = orEmptyString(modrinthVersion, v => ` ${kleur.blue(v.versionString)}`)
|
||||
const idString = kleur.gray(modrinthMod.slug + orEmptyString(modrinthVersion, v => `@${v.versionString}`))
|
||||
output.warn(`Unmet dependency: ${kleur.yellow(modrinthMod.title)}${versionString} ${idString}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loader.stop()
|
||||
})
|
41
src/commands/modrinth/export.ts
Normal file
41
src/commands/modrinth/export.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Command } from "commander"
|
||||
import { usePack } from "../../pack.js"
|
||||
import { output } from "../../utils/output.js"
|
||||
import fs from "fs-extra"
|
||||
import { generateOutputDirectory } from "../../modrinth/exporting.js"
|
||||
import kleur from "kleur"
|
||||
import { zipDirectory } from "../../utils/zip.js"
|
||||
|
||||
const EXPORT_OUTPUT_DIRECTORY_NAME = "modrinth"
|
||||
|
||||
export const exportCommand = new Command("export")
|
||||
.option("-s, --no-generate", "Skip regenerating the output directory.")
|
||||
.option("-z, --no-zip", "Skip creating a zipped .mrpack file.")
|
||||
.option("-c, --clean", "Remove the output directory afterwards.")
|
||||
.action(async options => {
|
||||
const pack = await usePack()
|
||||
const outputDirectoryPath = pack.paths.exports.resolve(EXPORT_OUTPUT_DIRECTORY_NAME)
|
||||
|
||||
const loader = output.startLoading("Exporting")
|
||||
|
||||
if (options.generate) {
|
||||
await output.withLoading(generateOutputDirectory(outputDirectoryPath), "Generating the output directory")
|
||||
output.println(kleur.green(`Generated Modrinth pack directory.`))
|
||||
}
|
||||
|
||||
if (options.zip) {
|
||||
const fileName = `${pack.manifest.slug}-${pack.manifest.meta.version}.mrpack`
|
||||
if (!(await fs.pathExists(outputDirectoryPath.toString())))
|
||||
output.failAndExit(`The ${kleur.yellow(EXPORT_OUTPUT_DIRECTORY_NAME)} export directory does not exist.\nRun the command without ${kleur.yellow("--no-generate")} to create it.`)
|
||||
|
||||
await output.withLoading(zipDirectory(outputDirectoryPath, pack.paths.exports.resolve(fileName)), `Creating ${kleur.yellow(".mrpack")} file`)
|
||||
output.println(kleur.green(`Created ${kleur.yellow(fileName)}`))
|
||||
}
|
||||
|
||||
if (options.clean) {
|
||||
await fs.remove(outputDirectoryPath.toString())
|
||||
output.println(kleur.green(`Removed the ${kleur.yellow(EXPORT_OUTPUT_DIRECTORY_NAME)} directory.`))
|
||||
}
|
||||
|
||||
loader.stop()
|
||||
})
|
20
src/commands/modrinth/index.ts
Normal file
20
src/commands/modrinth/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Command } from "commander"
|
||||
import { activateCommand } from "./activate.js"
|
||||
import dedent from "dedent"
|
||||
import kleur from "kleur"
|
||||
import { openCommand } from "./open.js"
|
||||
import { exportCommand } from "./export.js"
|
||||
|
||||
export const modrinthCommand = new Command("modrinth")
|
||||
.alias("mr")
|
||||
.addCommand(activateCommand)
|
||||
.addCommand(exportCommand)
|
||||
.addCommand(openCommand)
|
||||
.addHelpText("afterAll", dedent`
|
||||
\n${kleur.yellow("<code>")} may be one of the following:
|
||||
- URL or slug of a Modrinth mod (${kleur.yellow("https://modrinth.com/mod/sodium")} or ${kleur.yellow("sodium")})
|
||||
- URL of a Modrinth mod version (${kleur.yellow("https://modrinth.com/mod/sodium/version/mc1.19-0.4.2")})
|
||||
- slug of a Modrinth mod and a version with a ${kleur.yellow("@")} in between (${kleur.yellow("sodium@mc1.19-0.4.2")})
|
||||
- Modrinth project ID (${kleur.yellow("AANobbMI")} for Sodium)
|
||||
- Modrinth version ID, prefixed with ${kleur.yellow("@")} (${kleur.yellow("@Yp8wLY1P")} for Sodium mc1.19-0.4.2)
|
||||
`)
|
24
src/commands/modrinth/open.ts
Normal file
24
src/commands/modrinth/open.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Command } from "commander"
|
||||
import { usePack } from "../../pack.js"
|
||||
import { output } from "../../utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import open from "open"
|
||||
|
||||
export const openCommand = new Command("open")
|
||||
.argument("<path>")
|
||||
.action(async pathString => {
|
||||
const pack = await usePack()
|
||||
const metaFile = pack.getMetaFileFromInput(pathString)
|
||||
|
||||
if (metaFile.content.source?.type === "modrinth") {
|
||||
const { modId } = metaFile.content.source
|
||||
const url = `https://modrinth.com/mod/${encodeURIComponent(modId)}`
|
||||
|
||||
try {
|
||||
await open(url, { wait: false })
|
||||
output.printlnWrapping(kleur.green(`Opened ${kleur.yellow(url)} in your default browser.`))
|
||||
} catch (e: unknown) {
|
||||
output.fail(`Could not open ${kleur.yellow(url)} in a browser.`)
|
||||
}
|
||||
} else output.failAndExit(`${kleur.yellow(metaFile.relativePath.toString())} is not a Modrinth mod.`)
|
||||
})
|
|
@ -1,201 +0,0 @@
|
|||
import { Command } from "commander"
|
||||
import { Mod, usePack } from "../pack.js"
|
||||
import fs from "fs-extra"
|
||||
import dedent from "dedent"
|
||||
import kleur from "kleur"
|
||||
import { getLANAddress, getSha512HexHash, httpServeDirectory, optionParsePositiveInteger } from "../utils.js"
|
||||
import { output } from "../output.js"
|
||||
import { Visitor, walk } from "@root/walk"
|
||||
import { Path } from "../path.js"
|
||||
|
||||
const packwizCommand = new Command("packwiz")
|
||||
.alias("pw")
|
||||
|
||||
packwizCommand.command("import")
|
||||
.description("Import a packwiz pack.")
|
||||
.action(async () => {
|
||||
output.failAndExit("Not implemented.")
|
||||
// TODO: Import packwiz pack
|
||||
})
|
||||
|
||||
packwizCommand.command("serve")
|
||||
.description("Start an HTTP server in the packwiz directory.")
|
||||
.option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
|
||||
.option("-e, --expose", "Expose the HTTP server on all interfaces.")
|
||||
.action(async options => {
|
||||
const pack = await usePack()
|
||||
const directoryPath = pack.paths.generated.resolve("packwiz")
|
||||
if (!(await fs.pathExists(directoryPath.toString())))
|
||||
output.failAndExit(`The ${kleur.yellow("packwiz")} directory does not exist. Generate it by running ${kleur.yellow("horizr packwiz export")}.`)
|
||||
|
||||
await serveExportOutput(directoryPath, options.port, options.expose)
|
||||
})
|
||||
|
||||
// packwizCommand.command("dev")
|
||||
// .description("serve + export with hot-reloading.")
|
||||
// .option("-s, --server", "Use server overrides instead of client overrides.")
|
||||
// .option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
|
||||
// .option("-e, --expose", "Expose the HTTP server on all interfaces.")
|
||||
// .action(async options => {
|
||||
//
|
||||
// })
|
||||
|
||||
packwizCommand.command("export")
|
||||
.description("Generate a packwiz pack in the packwiz directory.")
|
||||
.option("-s, --server", "Use server overrides instead of client overrides.")
|
||||
.action(async options => {
|
||||
await runExport(options.server)
|
||||
})
|
||||
|
||||
async function runExport(forServer: boolean) {
|
||||
const pack = await usePack()
|
||||
|
||||
const loader = output.startLoading("Generating")
|
||||
|
||||
const outputDirectoryPath = pack.paths.generated.resolve("packwiz")
|
||||
await fs.remove(outputDirectoryPath.toString())
|
||||
await fs.mkdirp(outputDirectoryPath.resolve("mods").toString())
|
||||
|
||||
const indexedFiles: IndexedFile[] = []
|
||||
await indexMods(indexedFiles, outputDirectoryPath)
|
||||
|
||||
loader.setText(`Copying and hashing ${forServer ? "server" : "client"} overrides`)
|
||||
await copyOverrides(indexedFiles, outputDirectoryPath, forServer)
|
||||
|
||||
loader.setText(`Writing ${kleur.yellow("index.toml")}`)
|
||||
|
||||
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
|
||||
|
||||
loader.stop()
|
||||
output.println(kleur.green("Generated packwiz pack"))
|
||||
|
||||
return {
|
||||
indexedFiles
|
||||
}
|
||||
}
|
||||
|
||||
interface IndexedFile {
|
||||
path: string
|
||||
sha512HashHex: string
|
||||
isMeta: boolean
|
||||
}
|
||||
|
||||
async function writeAndIndexModMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: Path, mod: Mod) {
|
||||
const content = dedent`
|
||||
name = ${JSON.stringify(mod.modFile.name)}
|
||||
filename = ${JSON.stringify(mod.modFile.file.name)}
|
||||
side = "${mod.modFile.side.replace("client-server", "both")}"
|
||||
|
||||
[download]
|
||||
hash-format = "sha512"
|
||||
hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)}
|
||||
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
|
||||
`
|
||||
|
||||
const path = outputDirectoryPath.resolve(`mods/${mod.id}.toml`)
|
||||
await fs.writeFile(path.toString(), content)
|
||||
|
||||
indexedFiles.push({
|
||||
path: `mods/${mod.id}.toml`,
|
||||
isMeta: true,
|
||||
sha512HashHex: await getSha512HexHash(content)
|
||||
})
|
||||
}
|
||||
|
||||
async function indexMods(indexedFiles: IndexedFile[], outputDirectoryPath: Path, warn: boolean = true) {
|
||||
const pack = await usePack()
|
||||
|
||||
for (const mod of pack.mods) {
|
||||
if (warn && !mod.modFile.enabled) output.warn(`${kleur.yellow(mod.modFile.name)} is disabled and will not be included.`)
|
||||
|
||||
await output.withLoading(
|
||||
writeAndIndexModMetaFile(indexedFiles, outputDirectoryPath, mod),
|
||||
`Generating ${kleur.yellow(mod.id + ".toml")} (${indexedFiles.length + 1}/${pack.mods.length})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOverrides(indexedFiles: IndexedFile[], outputDirectoryPath:Path, forServer: boolean) {
|
||||
const pack = await usePack()
|
||||
|
||||
const createVisitor = (overridesDirectoryPath: Path): Visitor => async (error, path, dirent) => {
|
||||
const relativePath = overridesDirectoryPath.relative(path)
|
||||
|
||||
if (error) output.warn(`${kleur.yellow(relativePath.toString())}: ${error.message}`)
|
||||
else {
|
||||
if (dirent.name.startsWith(".")) return false
|
||||
if (dirent.isFile()) {
|
||||
const outputPath = outputDirectoryPath.resolve(relativePath)
|
||||
await fs.mkdirp(outputPath.getParent().toString())
|
||||
await fs.copy(path, outputPath.toString())
|
||||
|
||||
indexedFiles.push({
|
||||
path: relativePath.toString(),
|
||||
isMeta: false,
|
||||
sha512HashHex: await getSha512HexHash(await fs.readFile(overridesDirectoryPath.resolve(path).toString()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const specificOverridesDirectoryPath = pack.paths.overrides[forServer ? "server" : "client"]
|
||||
const universalOverridesDirectoryPath = pack.paths.overrides["client-server"]
|
||||
|
||||
if (await fs.pathExists(specificOverridesDirectoryPath.toString())) await walk(specificOverridesDirectoryPath.toString(), createVisitor(specificOverridesDirectoryPath))
|
||||
if (await fs.pathExists(universalOverridesDirectoryPath.toString())) await walk(universalOverridesDirectoryPath.toString(), createVisitor(universalOverridesDirectoryPath))
|
||||
}
|
||||
|
||||
async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: Path) {
|
||||
const pack = await usePack()
|
||||
|
||||
const index = dedent`
|
||||
hash-format = "sha512"
|
||||
|
||||
${indexedFiles.map(file => dedent`
|
||||
[[files]]
|
||||
file = ${JSON.stringify(file.path)}
|
||||
hash = "${file.sha512HashHex}"
|
||||
metafile = ${file.isMeta}
|
||||
`).join("\n\n")}
|
||||
`
|
||||
|
||||
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
||||
const indexHash = await getSha512HexHash(index)
|
||||
|
||||
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
|
||||
name = ${JSON.stringify(pack.horizrFile.meta.name)}
|
||||
authors = ${JSON.stringify(pack.horizrFile.meta.authors.join(", "))}\
|
||||
${pack.horizrFile.meta.description === undefined ? "" : "\n" + `description = ${JSON.stringify(pack.horizrFile.meta.description)}`}
|
||||
pack-format = "packwiz:1.0.0"
|
||||
|
||||
[versions]
|
||||
minecraft = ${JSON.stringify(pack.horizrFile.versions.minecraft)}
|
||||
fabric = ${JSON.stringify(pack.horizrFile.versions.fabric)}
|
||||
|
||||
[index]
|
||||
file = "index.toml"
|
||||
hash-format = "sha512"
|
||||
hash = "${indexHash}"
|
||||
`)
|
||||
}
|
||||
|
||||
async function serveExportOutput(path: Path, port: number, expose: boolean) {
|
||||
const lanAddress = await getLANAddress()
|
||||
const localAddress = `http://localhost:${port}/pack.toml`
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
httpServeDirectory(path, port, expose, () => {
|
||||
if (expose) {
|
||||
output.println(dedent`
|
||||
${kleur.green("Serving at")}
|
||||
Local: ${kleur.yellow(localAddress)}
|
||||
Network: ${kleur.yellow(`http://${lanAddress}:${port}/pack.toml`)}
|
||||
`)
|
||||
} else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`)
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export { packwizCommand}
|
48
src/commands/packwiz/export.ts
Normal file
48
src/commands/packwiz/export.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Command } from "commander"
|
||||
import kleur from "kleur"
|
||||
import { Side, usePack } from "../../pack.js"
|
||||
import { output } from "../../utils/output.js"
|
||||
import fs from "fs-extra"
|
||||
import { IndexedFile, PACKWIZ_EXPORT_DIRECTORY_NAME, writeAndIndexMetaFile, writeAndIndexStaticSourceFile, writeIndexAndPackManifest } from "../../packwiz/exporting.js"
|
||||
|
||||
export const exportCommand = new Command("export")
|
||||
.description("Export a packwiz pack.")
|
||||
.option("-s, --server", "Use server overrides instead of client overrides. Only applies to static files.")
|
||||
.action(async (path, command) => {
|
||||
const pack = await usePack()
|
||||
const side: Side = command.opts().server ? "server" : "client"
|
||||
const loader = output.startLoading("Exporting")
|
||||
|
||||
const outputDirectoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME)
|
||||
await fs.remove(outputDirectoryPath.toString())
|
||||
|
||||
const indexedFiles: IndexedFile[] = []
|
||||
|
||||
let i = 0
|
||||
for (const metaFile of pack.metaFiles) {
|
||||
i++
|
||||
if (!metaFile.content.enabled) continue
|
||||
|
||||
await output.withLoading(
|
||||
writeAndIndexMetaFile(indexedFiles, outputDirectoryPath, metaFile),
|
||||
`Exporting ${kleur.yellow(metaFile.getDisplayString())} (${i}/${pack.metaFiles.length})`
|
||||
)
|
||||
}
|
||||
|
||||
i = 0
|
||||
for (const staticSourceFile of pack.staticSourceFiles) {
|
||||
i++
|
||||
if (staticSourceFile.side !== "universal" && staticSourceFile.side !== side) continue
|
||||
|
||||
await output.withLoading(
|
||||
writeAndIndexStaticSourceFile(indexedFiles, outputDirectoryPath, staticSourceFile),
|
||||
`Exporting ${kleur.yellow(staticSourceFile.relativePath.toString())} (${i}/${pack.metaFiles.length})`
|
||||
)
|
||||
}
|
||||
|
||||
loader.setText(`Creating ${kleur.yellow("index.toml")} and ${kleur.yellow("pack.toml")}`)
|
||||
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
|
||||
loader.stop()
|
||||
|
||||
output.println(kleur.green("Generated packwiz pack"))
|
||||
})
|
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[]>()
|
||||
|
|
128
src/files.ts
128
src/files.ts
|
@ -1,57 +1,55 @@
|
|||
import { SafeParseError, z, ZodRawShape } from "zod"
|
||||
import kleur from "kleur"
|
||||
import fs from "fs-extra"
|
||||
import * as process from "process"
|
||||
import { dirname } from "path"
|
||||
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||
import process from "process"
|
||||
import { findUp } from "find-up"
|
||||
import { output } from "./output.js"
|
||||
import { Path } from "./path.js"
|
||||
import { Dirent } from "fs"
|
||||
import { sides } from "./shared.js"
|
||||
import { output } from "./utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import { SafeParseError, z, ZodRawShape } from "zod"
|
||||
import fs from "fs-extra"
|
||||
import { dirname } from "path"
|
||||
import fastGlob from "fast-glob"
|
||||
import { sides } from "./pack.js"
|
||||
|
||||
export async function findPackDirectoryPath(): Promise<Path> {
|
||||
export async function findPackDirectoryPath(): Promise<AbsolutePath> {
|
||||
if (process.argv0.endsWith("/node")) { // run using pnpm
|
||||
return Path.createAbsolute("./test-pack")
|
||||
return envPaths.cwd.resolve("./test-pack")
|
||||
} else {
|
||||
const parent = await findUp("horizr.json")
|
||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`)
|
||||
const parent = await findUp(PACK_MANIFEST_FILE_NAME)
|
||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} could not be found in the current working directory or any parent.`)
|
||||
|
||||
return Path.createAbsolute(dirname(parent))
|
||||
return AbsolutePath.create(dirname(parent))
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
||||
packPath: Path,
|
||||
filePath: Path,
|
||||
schema: S
|
||||
): Promise<z.output<S> | null> {
|
||||
export async function writeJsonFile<S extends z.ZodObject<ZodRawShape>>(path: AbsolutePath, schema: S, data: z.input<S>) {
|
||||
await fs.mkdirp(path.parent().toString())
|
||||
await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 })
|
||||
}
|
||||
|
||||
export async function readJsonFile<S extends z.ZodObject<ZodRawShape>>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise<z.output<S> | null> {
|
||||
let data
|
||||
|
||||
try {
|
||||
data = await fs.readJson(packPath.resolve(filePath).toString())
|
||||
data = await fs.readJson(rootPath.resolve(specificPath).toString())
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`)
|
||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(specificPath.toString())} does not contain valid JSON.`)
|
||||
else return null
|
||||
}
|
||||
|
||||
const result = await schema.safeParseAsync(data)
|
||||
if (!result.success) {
|
||||
const error = (result as SafeParseError<unknown>).error
|
||||
return output.failAndExit(`${kleur.yellow(filePath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||
return output.failAndExit(`${kleur.yellow(specificPath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) {
|
||||
const absolutePath = packPath.resolve(filePath)
|
||||
await fs.mkdirp(absolutePath.getParent().toString())
|
||||
export const FORMAT_VERSION = 1
|
||||
export const PACK_MANIFEST_FILE_NAME = "horizr.json"
|
||||
|
||||
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
|
||||
}
|
||||
|
||||
const horizrFileSchema = z.object({
|
||||
formatVersion: z.string().or(z.number()),
|
||||
export const packManifestFileSchema = z.object({
|
||||
formatVersion: z.literal(FORMAT_VERSION),
|
||||
slug: z.string(),
|
||||
meta: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
|
@ -65,75 +63,49 @@ const horizrFileSchema = z.object({
|
|||
})
|
||||
})
|
||||
|
||||
export type HorizrFile = z.output<typeof horizrFileSchema>
|
||||
export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1
|
||||
export type PackManifest = z.output<typeof packManifestFileSchema>
|
||||
|
||||
export async function readHorizrFile(packPath: Path) {
|
||||
const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema)
|
||||
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
||||
if (data.formatVersion !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
||||
export const META_FILE_EXTENSION = "hm.json"
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const modFileModrinthSourceSchema = z.object({
|
||||
const metaFileModrinthSourceSchema = z.object({
|
||||
type: z.literal("modrinth"),
|
||||
modId: z.string(),
|
||||
versionId: z.string()
|
||||
})
|
||||
|
||||
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema>
|
||||
export type MetaFileModrinthSource = z.output<typeof metaFileModrinthSourceSchema>
|
||||
|
||||
const modFileDataSchema = z.object({
|
||||
version: z.string(),
|
||||
const metaFileContentVersionSchema = z.object({
|
||||
name: z.string(),
|
||||
size: z.number().int().min(0).optional(),
|
||||
fileName: z.string(),
|
||||
downloadUrl: z.string().url(),
|
||||
hashes: z.object({ // Adopted from Modrinth
|
||||
hashes: z.object({
|
||||
sha1: z.string(),
|
||||
sha512: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export type ModFileData = z.output<typeof modFileDataSchema>
|
||||
export type MetaFileContentVersion = z.output<typeof metaFileContentVersionSchema>
|
||||
|
||||
const modFileSchema = z.object({
|
||||
name: z.string(),
|
||||
export const metaFileContentSchema = z.object({
|
||||
formatVersion: z.literal(FORMAT_VERSION),
|
||||
displayName: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
ignoreUpdates: z.boolean().default(false),
|
||||
side: z.enum(sides),
|
||||
comment: z.string().optional(),
|
||||
file: modFileDataSchema,
|
||||
version: metaFileContentVersionSchema,
|
||||
source: z.discriminatedUnion("type", [
|
||||
modFileModrinthSourceSchema,
|
||||
metaFileModrinthSourceSchema,
|
||||
z.object({ type: z.literal("raw") })
|
||||
])
|
||||
]).and(z.object({
|
||||
ignoreUpdates: z.boolean().default(false)
|
||||
})).optional()
|
||||
})
|
||||
|
||||
export type ModFile = z.output<typeof modFileSchema>
|
||||
export type MetaFileContent = z.output<typeof metaFileContentSchema>
|
||||
|
||||
export async function readModFile(packPath: Path, modId: string): Promise<ModFile | null> {
|
||||
return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema)
|
||||
}
|
||||
|
||||
export async function writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
||||
await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data)
|
||||
}
|
||||
|
||||
export async function removeModFile(packPath: Path, modId: string): Promise<void> {
|
||||
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
|
||||
}
|
||||
|
||||
export async function readModIds(packPath: Path) {
|
||||
const modsPath = packPath.resolve("mods")
|
||||
await fs.mkdirp(modsPath.toString())
|
||||
const files = await fs.readdir(modsPath.toString(), { withFileTypes: true })
|
||||
|
||||
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
||||
}
|
||||
|
||||
export async function getOverrideDirents(overridesDirectoryPath: Path): Promise<Dirent[]> {
|
||||
if (!await fs.pathExists(overridesDirectoryPath.toString())) return []
|
||||
|
||||
return await fs.readdir(overridesDirectoryPath.toString(), { withFileTypes: true })
|
||||
}
|
||||
export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), {
|
||||
cwd: sourceDirectoryPath.toString(),
|
||||
followSymbolicLinks: false,
|
||||
onlyFiles: true
|
||||
}).then(paths => paths.map(path => RelativePath.create(path)))
|
||||
|
|
202
src/main.ts
202
src/main.ts
|
@ -1,198 +1,36 @@
|
|||
import { Command } from "commander"
|
||||
import kleur from "kleur"
|
||||
import { usePack } from "./pack.js"
|
||||
import loudRejection from "loud-rejection"
|
||||
import { modrinthCommand } from "./commands/modrinth.js"
|
||||
import { packwizCommand } from "./commands/packwiz.js"
|
||||
import dedent from "dedent"
|
||||
import { default as wrapAnsi } from "wrap-ansi"
|
||||
import { CURRENT_HORIZR_FILE_FORMAT_VERSION, HorizrFile, removeModFile } from "./files.js"
|
||||
import { output } from "./output.js"
|
||||
import figures from "figures"
|
||||
import yesno from "yesno"
|
||||
import { releaseChannelOrder } from "./shared.js"
|
||||
import fs from "fs-extra"
|
||||
import { Path } from "./path.js"
|
||||
import enquirer from "enquirer"
|
||||
import { clearCache } from "./utils.js"
|
||||
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js"
|
||||
import { AbsolutePath } from "./utils/path.js"
|
||||
import { output } from "./utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import loudRejection from "loud-rejection"
|
||||
import { clearGotCache } from "./utils/http.js"
|
||||
import { initCommand } from "./commands/init.js"
|
||||
import { infoCommand } from "./commands/info.js"
|
||||
import { updateCommand } from "./commands/update.js"
|
||||
import { packwizCommand } from "./commands/packwiz/index.js"
|
||||
import { modrinthCommand } from "./commands/modrinth/index.js"
|
||||
import { exportJsonSchemasCommand } from "./commands/exportJsonSchemas.js"
|
||||
|
||||
const program = new Command("horizr")
|
||||
.version(
|
||||
(await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version,
|
||||
(await fs.readJson(AbsolutePath.create(import.meta.url.slice(5)).parent().resolve("../package.json").toString())).version,
|
||||
"-v, --version"
|
||||
)
|
||||
.option("--clear-cache", "Clear the HTTP cache before doing the operation.")
|
||||
.on("option:clear-cache", () => {
|
||||
clearCache()
|
||||
clearGotCache()
|
||||
output.println(kleur.green("Cache was cleared.\n"))
|
||||
})
|
||||
|
||||
program.command("init <path>")
|
||||
.description("Initialize a new pack in the directory.")
|
||||
.action(async path => {
|
||||
const directoryPath = Path.create(path)
|
||||
const horizrFilePath = directoryPath.resolve("horizr.json")
|
||||
|
||||
if (await fs.pathExists(horizrFilePath.toString())) output.failAndExit(`${kleur.yellow("horizr.json")} already exists in the directory.`)
|
||||
|
||||
await fs.mkdirp(directoryPath.toString())
|
||||
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
|
||||
|
||||
const answers: any = await enquirer.prompt([
|
||||
{
|
||||
name: "name",
|
||||
type: "input",
|
||||
message: "Name",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "authors",
|
||||
type: "input",
|
||||
message: "Authors (comma-separated)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
message: "Description"
|
||||
},
|
||||
{
|
||||
name: "license",
|
||||
type: "text",
|
||||
message: "License (SPDX-ID)",
|
||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||
},
|
||||
{
|
||||
name: "minecraftVersion",
|
||||
type: "autocomplete",
|
||||
message: "Minecraft version",
|
||||
choices: minecraftVersions.map(version => ({
|
||||
name: version,
|
||||
value: version
|
||||
})),
|
||||
// @ts-expect-error
|
||||
limit: 10,
|
||||
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
|
||||
}
|
||||
])
|
||||
|
||||
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
|
||||
|
||||
const file: HorizrFile = {
|
||||
formatVersion: CURRENT_HORIZR_FILE_FORMAT_VERSION,
|
||||
meta: {
|
||||
name: answers.name,
|
||||
version: "1.0.0",
|
||||
description: answers.description === "" ? undefined : answers.description,
|
||||
authors: (answers.authors as string).split(", ").map(a => a.trim()),
|
||||
license: answers.license
|
||||
},
|
||||
versions: {
|
||||
minecraft: answers.minecraftVersion,
|
||||
fabric: fabricVersion
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeJson(horizrFilePath.toString(), file, { spaces: 2 })
|
||||
await fs.writeFile(directoryPath.resolve(".gitignore").toString(), "/generated/")
|
||||
|
||||
const relativePath = Path.create(process.cwd()).relative(directoryPath).toString()
|
||||
if (relativePath === "") output.println(kleur.green(`Successfully initialized pack.`))
|
||||
else output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(relativePath)}.`))
|
||||
})
|
||||
|
||||
program.command("info", { isDefault: true })
|
||||
.description("Print information about the pack.")
|
||||
.action(async () => {
|
||||
const pack = await usePack()
|
||||
const disabledModsCount = pack.mods.filter(mod => !mod.modFile.enabled).length
|
||||
const { description } = pack.horizrFile.meta
|
||||
|
||||
output.println(dedent`
|
||||
${kleur.underline(pack.horizrFile.meta.name)} ${kleur.dim(`(${pack.horizrFile.meta.version})`)}
|
||||
${description === undefined ? "" : wrapAnsi(description, process.stdout.columns) + "\n"}\
|
||||
|
||||
Authors: ${kleur.yellow(pack.horizrFile.meta.authors.join(", "))}
|
||||
License: ${kleur.yellow(pack.horizrFile.meta.license.toUpperCase())}
|
||||
Mods: ${kleur.yellow(pack.mods.length.toString())}${disabledModsCount === 0 ? "" : ` (${disabledModsCount} disabled)`}
|
||||
|
||||
Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)}
|
||||
`)
|
||||
})
|
||||
|
||||
program.command("remove <code>")
|
||||
.description("Remove the mod from the pack.")
|
||||
.action(async code => {
|
||||
const pack = await usePack()
|
||||
const mod = pack.findModByCodeOrFail(code)
|
||||
|
||||
await removeModFile(pack.paths.root, mod.id)
|
||||
|
||||
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
||||
})
|
||||
|
||||
program.command("update [code]")
|
||||
.description("Check for updates of all mods or update a specific mod")
|
||||
.option("-y, --yes", "Skip confirmations")
|
||||
.option("-b, --allow-beta", "Allow beta versions")
|
||||
.option("-a, --allow-alpha", "Allow alpha and beta versions")
|
||||
.action(async (code, options) => {
|
||||
const pack = await usePack()
|
||||
const allowedReleaseChannels = releaseChannelOrder.slice(releaseChannelOrder.indexOf(options.allowAlpha ? "alpha" : options.allowBeta ? "beta" : "release"))
|
||||
|
||||
if (code === undefined) {
|
||||
const updates = await pack.checkForUpdates(allowedReleaseChannels)
|
||||
|
||||
if (updates.length === 0) output.println(kleur.green("Everything up-to-date."))
|
||||
else {
|
||||
output.println(dedent`
|
||||
${kleur.underline("Available updates")}
|
||||
|
||||
${updates.map(update => `- ${kleur.gray(update.mod.id)} ${update.mod.modFile.name}: ${kleur.red(update.activeVersion)} ${figures.arrowRight} ${kleur.green(update.availableVersion)}`).join("\n")}
|
||||
`)
|
||||
}
|
||||
} else {
|
||||
const mod = pack.findModByCodeOrFail(code)
|
||||
const update = await output.withLoading(mod.checkForUpdate(allowedReleaseChannels), "Checking for an update")
|
||||
|
||||
if (update === null) {
|
||||
output.println(kleur.green("No update available."))
|
||||
} else {
|
||||
if (update.changelog === null) {
|
||||
output.println(`No changelog available for ${kleur.bold(update.availableVersion)}.`)
|
||||
} else {
|
||||
output.println(`${kleur.underline("Changelog")} for ${kleur.bold().yellow(update.availableVersion)}\n`)
|
||||
output.printlnWrapping(update.changelog)
|
||||
}
|
||||
|
||||
output.println("")
|
||||
|
||||
const confirmed = options.yes || await yesno({
|
||||
question: "Apply the update? [Y/n]",
|
||||
defaultValue: true,
|
||||
invalid: () => {}
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await output.withLoading(update.apply(), "Updating")
|
||||
output.println(kleur.green(`Successfully updated ${kleur.yellow(update.mod.modFile.name)} to ${kleur.yellow(update.availableVersion)}.`))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCommand(modrinthCommand)
|
||||
.addCommand(packwizCommand)
|
||||
.addCommand(exportJsonSchemasCommand, { hidden: true })
|
||||
.addCommand(infoCommand)
|
||||
.addCommand(initCommand)
|
||||
.addCommand(updateCommand)
|
||||
|
||||
loudRejection(stack => {
|
||||
output.failAndExit(stack)
|
||||
})
|
||||
|
||||
await program
|
||||
.addCommand(packwizCommand)
|
||||
.addCommand(modrinthCommand)
|
||||
.addHelpText("after", "\n" + dedent`
|
||||
${kleur.blue("code")} can be one of the following:
|
||||
- The name of a file in the ${kleur.yellow("mods")} directory, optionally without the ${kleur.yellow(".json")} extension
|
||||
- The ID of a Modrinth Project, prefixed with ${kleur.yellow("mr:")}
|
||||
- The ID of a Modrinth Version, prefixed with ${kleur.yellow("mrv:")}
|
||||
`)
|
||||
.parseAsync(process.argv)
|
||||
await program.parseAsync(process.argv)
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import { HTTPError, Response } from "got"
|
||||
import { output } from "../utils/output.js"
|
||||
import kleur from "kleur"
|
||||
import { delay, got } from "../utils.js"
|
||||
import { output } from "../output.js"
|
||||
import { dependencyToRelatedVersionType } from "./utils.js"
|
||||
import { ReleaseChannel } from "../shared.js"
|
||||
import { got } from "../utils/http.js"
|
||||
import { delay } from "../utils/promises.js"
|
||||
import { dependencyToRelatedVersionType } from "./index.js"
|
||||
import { ReleaseChannel } from "../pack.js"
|
||||
import { orEmptyString } from "../utils/strings.js"
|
||||
|
||||
const BASE_URL = "https://api.modrinth.com"
|
||||
|
||||
async function getModrinthApiOptional(url: string): Promise<any | null> {
|
||||
let response: Response
|
||||
|
||||
while (true) {
|
||||
response = await got(url, {
|
||||
prefixUrl: "https://api.modrinth.com",
|
||||
prefixUrl: BASE_URL,
|
||||
throwHttpErrors: false,
|
||||
retry: {
|
||||
limit: 3,
|
||||
|
@ -56,7 +60,7 @@ async function getModrinthApiOptional(url: string): Promise<any | null> {
|
|||
|
||||
async function getModrinthApi(url: string): Promise<any> {
|
||||
const response = await getModrinthApiOptional(url)
|
||||
if (response === null) return output.failAndExit("Request failed with status code 404.")
|
||||
if (response === null) return output.failAndExit(`Request failed with status code 404: ${kleur.yellow(BASE_URL + "/" + url)}`)
|
||||
return response
|
||||
}
|
||||
|
||||
|
@ -188,8 +192,8 @@ export const modrinthApi = {
|
|||
updateDate: new Date(response.updated)
|
||||
}
|
||||
},
|
||||
async listVersions(idOrSlug: string, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`)
|
||||
async listVersions(idOrSlug: string, minecraftVersion?: string): Promise<ModrinthVersion[]> {
|
||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]${orEmptyString(minecraftVersion, v => `&game_versions=["${v}"]`)}`)
|
||||
|
||||
return response.map(transformApiModVersion)
|
||||
},
|
||||
|
|
61
src/modrinth/exporting.ts
Normal file
61
src/modrinth/exporting.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { AbsolutePath } from "../utils/path.js"
|
||||
import { output } from "../utils/output.js"
|
||||
import fs from "fs-extra"
|
||||
import { Side, usePack } from "../pack.js"
|
||||
import kleur from "kleur"
|
||||
import { mapNotNull } from "../utils/collections.js"
|
||||
|
||||
const overridesDirectoryNameBySide: Record<Side, string> = {
|
||||
client: "client-overrides",
|
||||
server: "server-overrides",
|
||||
universal: "overrides"
|
||||
}
|
||||
|
||||
export async function generateOutputDirectory(outputDirectoryPath: AbsolutePath) {
|
||||
const pack = await usePack()
|
||||
await fs.remove(outputDirectoryPath.toString())
|
||||
await fs.mkdirp(outputDirectoryPath.toString())
|
||||
|
||||
await fs.writeJson(outputDirectoryPath.resolve("modrinth.index.json").toString(), {
|
||||
formatVersion: 1,
|
||||
game: "minecraft",
|
||||
versionId: pack.manifest.meta.version,
|
||||
name: pack.manifest.meta.name,
|
||||
summary: pack.manifest.meta.description,
|
||||
dependencies: {
|
||||
minecraft: pack.manifest.versions.minecraft,
|
||||
"fabric-loader": pack.manifest.versions.fabric
|
||||
},
|
||||
files: mapNotNull(pack.metaFiles, metaFile => metaFile.content.enabled ? ({
|
||||
path: metaFile.effectivePath.toString(),
|
||||
hashes: {
|
||||
sha1: metaFile.content.version.hashes.sha1,
|
||||
sha512: metaFile.content.version.hashes.sha512
|
||||
},
|
||||
env: {
|
||||
client: metaFile.side === "client" || metaFile.side === "universal" ? "required" : "unsupported",
|
||||
server: metaFile.side === "server" || metaFile.side === "universal" ? "required" : "unsupported"
|
||||
},
|
||||
downloads: [
|
||||
metaFile.content.version.downloadUrl
|
||||
],
|
||||
fileSize: metaFile.content.version.size
|
||||
}) : null)
|
||||
}, { spaces: 2 })
|
||||
|
||||
let i = 0
|
||||
for (const staticSourceFile of pack.staticSourceFiles) {
|
||||
i++
|
||||
const loader = output.startLoading(`Exporting static source file (${i}/${pack.staticSourceFiles.length}): ${kleur.yellow(staticSourceFile.relativePath.toString())}`)
|
||||
const outputPath = outputDirectoryPath.resolve(overridesDirectoryNameBySide[staticSourceFile.side], staticSourceFile.effectivePath)
|
||||
await fs.mkdirp(outputPath.parent().toString())
|
||||
await fs.copy(staticSourceFile.absolutePath.toString(), outputPath.toString())
|
||||
|
||||
// Workaround for https://github.com/PolyMC/PolyMC/issues/1060
|
||||
if (staticSourceFile.side === "client") {
|
||||
await fs.mkdirp(outputDirectoryPath.resolve(overridesDirectoryNameBySide.universal, staticSourceFile.effectivePath).parent().toString())
|
||||
}
|
||||
|
||||
loader.stop()
|
||||
}
|
||||
}
|
128
src/modrinth/index.ts
Normal file
128
src/modrinth/index.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { IterableElement } from "type-fest"
|
||||
import { modrinthApi, ModrinthMod, ModrinthVersion, ModrinthVersionFile, ModrinthVersionRelation } from "./api.js"
|
||||
import { sortBy } from "lodash-es"
|
||||
import { MetaFile, Pack, releaseChannelOrder } from "../pack.js"
|
||||
import { MetaFileContentVersion } from "../files.js"
|
||||
import { output } from "../utils/output.js"
|
||||
import kleur from "kleur"
|
||||
|
||||
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
||||
required: "hard_dependency",
|
||||
optional: "soft_dependency",
|
||||
embedded: "embedded_dependency",
|
||||
incompatible: "incompatible"
|
||||
}
|
||||
|
||||
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
||||
|
||||
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
||||
modrinthVersion.supportedMinecraftVersions.includes(pack.manifest.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric")
|
||||
|
||||
export function getMetaFileContentVersionForModrinth(modrinthVersion: ModrinthVersion): MetaFileContentVersion {
|
||||
const modrinthVersionFile = findCorrectModVersionFile(modrinthVersion.files)
|
||||
|
||||
return {
|
||||
name: modrinthVersion.versionString,
|
||||
fileName: modrinthVersionFile.fileName,
|
||||
hashes: {
|
||||
sha1: modrinthVersionFile.hashes.sha1,
|
||||
sha512: modrinthVersionFile.hashes.sha512
|
||||
},
|
||||
downloadUrl: modrinthVersionFile.url,
|
||||
size: modrinthVersionFile.sizeInBytes
|
||||
}
|
||||
}
|
||||
|
||||
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
||||
const primary = files.find(file => file.isPrimary)
|
||||
|
||||
if (primary !== undefined) return primary
|
||||
|
||||
// shortest file name
|
||||
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
||||
}
|
||||
|
||||
export const getSideOfModrinthMod = (modrinthMod: ModrinthMod) =>
|
||||
modrinthMod.serverSide !== "unsupported" && modrinthMod.clientSide !== "unsupported"
|
||||
? "universal"
|
||||
: modrinthMod.clientSide !== "unsupported" ? "client" : "server"
|
||||
|
||||
export async function resolveModrinthCode(code: string): Promise<{ modrinthMod: ModrinthMod; modrinthVersion: ModrinthVersion | null }> {
|
||||
const resolveMod = async (slugOrId: string) => {
|
||||
const modrinthMod = await modrinthApi.getMod(slugOrId)
|
||||
if (modrinthMod === null) return output.failAndExit(`Unknown mod: ${kleur.yellow(slugOrId)}`)
|
||||
return {
|
||||
modrinthMod,
|
||||
modrinthVersion: null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveVersionByName = async (modrinthMod: ModrinthMod, name: string) => {
|
||||
const modrinthVersions = await modrinthApi.listVersions(modrinthMod.id)
|
||||
|
||||
const modrinthVersion = modrinthVersions.find(v => v.versionString === name)
|
||||
if (modrinthVersion === undefined) return output.failAndExit(`Unknown version: ${kleur.yellow(name)}`)
|
||||
|
||||
return {
|
||||
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
|
||||
modrinthVersion
|
||||
}
|
||||
}
|
||||
|
||||
const resolveVersion = async (id: string) => {
|
||||
const modrinthVersion = await modrinthApi.getVersion(id)
|
||||
if (modrinthVersion === null) return output.failAndExit(`Unknown version: ${kleur.yellow(id)}`)
|
||||
|
||||
return {
|
||||
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
|
||||
modrinthVersion
|
||||
}
|
||||
}
|
||||
|
||||
const parts = code.split("@")
|
||||
if (parts.length === 2 && parts[0] === "") return resolveVersion(code.slice(1))
|
||||
if (parts.length <= 2 && !code.startsWith("https://")) {
|
||||
const value = await resolveMod(parts[0])
|
||||
|
||||
if (parts.length === 2) return resolveVersionByName(value.modrinthMod, parts[1])
|
||||
else return value
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(code)
|
||||
const pathSegments = url.pathname.slice(1).split("/")
|
||||
if (!(code.startsWith("https://modrinth.com/mod/") && (pathSegments.length === 2 || pathSegments.length === 4)))
|
||||
output.failAndExit("Only Modrinth mod and version URLs are supported.")
|
||||
|
||||
const value = await resolveMod(pathSegments[1])
|
||||
|
||||
if (pathSegments.length === 4) return resolveVersionByName(value.modrinthMod, pathSegments[3])
|
||||
else return value
|
||||
} catch (e: unknown) {
|
||||
// TypeError means code is not a URL
|
||||
if (!(e instanceof TypeError)) throw e
|
||||
}
|
||||
|
||||
return output.failAndExit(`Invalid ${kleur.yellow("<code>")}: ${kleur.yellow(code)}`)
|
||||
}
|
||||
|
||||
export const findMetaFileForModrinthMod = (metaFiles: MetaFile[], modrinthModId: string) =>
|
||||
metaFiles.find(metaFile => metaFile.content.source?.type === "modrinth" && metaFile.content.source.modId === modrinthModId) ?? null
|
||||
|
||||
export async function resolveFullRelation(relation: ModrinthVersionRelation) {
|
||||
if (relation.projectId === null) {
|
||||
const modrinthVersion = (await modrinthApi.getVersion(relation.versionId!))!
|
||||
|
||||
return {
|
||||
modrinthVersion,
|
||||
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!
|
||||
}
|
||||
} else {
|
||||
const modrinthMod = (await modrinthApi.getMod(relation.projectId))!
|
||||
|
||||
return {
|
||||
modrinthMod,
|
||||
modrinthVersion: null
|
||||
}
|
||||
}
|
||||
}
|
56
src/modrinth/updating.ts
Normal file
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,87 +0,0 @@
|
|||
import { IterableElement } from "type-fest"
|
||||
import { sortBy } from "lodash-es"
|
||||
import { Mod, Pack, usePack } from "../pack.js"
|
||||
import { ModFile, ModFileData, ModFileModrinthSource } from "../files.js"
|
||||
import { pathExists } from "fs-extra"
|
||||
import { nanoid } from "nanoid/non-secure"
|
||||
import { output } from "../output.js"
|
||||
import kleur from "kleur"
|
||||
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
||||
import { releaseChannelOrder } from "../shared.js"
|
||||
|
||||
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
||||
required: "hard_dependency",
|
||||
optional: "soft_dependency",
|
||||
embedded: "embedded_dependency",
|
||||
incompatible: "incompatible"
|
||||
}
|
||||
|
||||
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
||||
|
||||
export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<(Mod & { modFile: ModFile & { source: ModFileModrinthSource } }) | null> {
|
||||
const pack = await usePack()
|
||||
|
||||
return (
|
||||
pack.mods.find(
|
||||
mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === modrinthMod.id
|
||||
) as (Mod & { modFile: Mod & { source: ModFileModrinthSource } }) | undefined
|
||||
) ?? null
|
||||
}
|
||||
|
||||
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
||||
modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric")
|
||||
|
||||
export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData {
|
||||
const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files)
|
||||
|
||||
return {
|
||||
version: modrinthModVersion.versionString,
|
||||
hashes: {
|
||||
sha1: modrinthVersionFile.hashes.sha1,
|
||||
sha512: modrinthVersionFile.hashes.sha512
|
||||
},
|
||||
downloadUrl: modrinthVersionFile.url,
|
||||
name: modrinthVersionFile.fileName,
|
||||
size: modrinthVersionFile.sizeInBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion) {
|
||||
const pack = await usePack()
|
||||
let id = modrinthMod.slug
|
||||
|
||||
if (await pathExists(pack.paths.mods.resolve(`${id}.json`).toString())) {
|
||||
const oldId = id
|
||||
id = `${id}-${nanoid(5)}`
|
||||
|
||||
output.warn(
|
||||
`There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` +
|
||||
`The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}`
|
||||
)
|
||||
}
|
||||
|
||||
const isClientSupported = modrinthMod.clientSide !== "unsupported"
|
||||
const isServerSupported = modrinthMod.serverSide !== "unsupported"
|
||||
|
||||
await pack.addMod(id, {
|
||||
name: modrinthMod.title,
|
||||
enabled: true,
|
||||
ignoreUpdates: false,
|
||||
side: isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server",
|
||||
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
|
||||
source: {
|
||||
type: "modrinth",
|
||||
modId: modrinthMod.id,
|
||||
versionId: modrinthVersion.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
||||
const primary = files.find(file => file.isPrimary)
|
||||
|
||||
if (primary !== undefined) return primary
|
||||
|
||||
// shortest file name
|
||||
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
||||
}
|
288
src/pack.ts
288
src/pack.ts
|
@ -1,173 +1,165 @@
|
|||
import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
|
||||
import { output } from "./output.js"
|
||||
import pLimit from "p-limit"
|
||||
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||
import {
|
||||
findPackDirectoryPath,
|
||||
PackManifest,
|
||||
packManifestFileSchema,
|
||||
MetaFileContent,
|
||||
PACK_MANIFEST_FILE_NAME,
|
||||
metaFileContentSchema,
|
||||
writeJsonFile,
|
||||
listSourceFiles,
|
||||
readJsonFile, META_FILE_EXTENSION
|
||||
} from "./files.js"
|
||||
import { z, ZodRawShape } from "zod"
|
||||
import { fetchModrinthModUpdates } from "./modrinth/updating.js"
|
||||
import { createCpuCoreLimiter } from "./utils/promises.js"
|
||||
import { output } from "./utils/output.js"
|
||||
import pathModule from "path"
|
||||
import kleur from "kleur"
|
||||
import { modrinthApi } from "./modrinth/api.js"
|
||||
import semver from "semver"
|
||||
import { Path } from "./path.js"
|
||||
import { ReleaseChannel, Side, sides } from "./shared.js"
|
||||
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
|
||||
import { orEmptyString } from "./utils/strings.js"
|
||||
import fs from "fs-extra"
|
||||
|
||||
export type ReleaseChannel = "alpha" | "beta" | "release"
|
||||
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
||||
|
||||
export type Side = "client" | "server" | "universal"
|
||||
export const sides: [Side, ...Side[]] = ["client", "server", "universal"]
|
||||
|
||||
export interface Pack {
|
||||
manifest: PackManifest
|
||||
|
||||
paths: {
|
||||
root: AbsolutePath
|
||||
source: AbsolutePath
|
||||
exports: AbsolutePath
|
||||
}
|
||||
|
||||
metaFiles: MetaFile[]
|
||||
staticSourceFiles: StaticSourceFile[]
|
||||
getMetaFile(path: RelativePath): MetaFile | null
|
||||
getMetaFileFromInput(input: string): MetaFile
|
||||
getEffectiveMetaFile(path: RelativePath, side: Side): MetaFile | null
|
||||
registerCreatedSourceFile(path: RelativePath): Promise<void>
|
||||
|
||||
readSourceJsonFile<S extends z.ZodObject<ZodRawShape>>(path: RelativePath, schema: S): Promise<z.output<S> | null>
|
||||
}
|
||||
|
||||
export interface SourceFile {
|
||||
isStatic: boolean
|
||||
isMod: boolean
|
||||
side: Side
|
||||
relativePath: RelativePath
|
||||
absolutePath: AbsolutePath
|
||||
effectivePath: RelativePath
|
||||
}
|
||||
|
||||
export interface MetaFile extends SourceFile {
|
||||
isStatic: false
|
||||
content: MetaFileContent
|
||||
fetchUpdates: null | ((allowedReleaseChannels: ReleaseChannel[]) => Promise<Update[]>)
|
||||
saveContent(): Promise<void>
|
||||
|
||||
getDisplayString(): string
|
||||
}
|
||||
|
||||
export interface StaticSourceFile extends SourceFile {
|
||||
isStatic: true
|
||||
}
|
||||
|
||||
export interface Update {
|
||||
mod: Mod
|
||||
activeVersion: string
|
||||
availableVersion: string
|
||||
of: MetaFile
|
||||
versionString: string
|
||||
changelog: string | null
|
||||
apply(): Promise<void>
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
paths: {
|
||||
root: Path,
|
||||
mods: Path,
|
||||
generated: Path,
|
||||
overrides: Record<Side, Path>
|
||||
},
|
||||
horizrFile: HorizrFile
|
||||
mods: Mod[]
|
||||
|
||||
addMod(id: string, file: ModFile): Promise<void>
|
||||
findModByCode(code: string): Mod | null
|
||||
findModByCodeOrFail(code: string): Mod
|
||||
validateOverridesDirectories(): Promise<void>
|
||||
|
||||
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
|
||||
}
|
||||
|
||||
export interface Mod {
|
||||
id: string
|
||||
|
||||
modFile: ModFile
|
||||
saveModFile(): Promise<void>
|
||||
|
||||
checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null>
|
||||
}
|
||||
export const EXPORTS_DIRECTORY_NAME = "exports"
|
||||
|
||||
let pack: Pack
|
||||
|
||||
export async function usePack(): Promise<Pack> {
|
||||
if (pack === undefined) {
|
||||
const rootDirectoryPath = await findPackDirectoryPath()
|
||||
const overridesDirectoryPath = rootDirectoryPath.resolve("overrides")
|
||||
const sourceDirectoryPath = rootDirectoryPath.resolve("src")
|
||||
const readSourceJsonFile: Pack["readSourceJsonFile"] = async (path, schema) => readJsonFile(sourceDirectoryPath, path, schema)
|
||||
|
||||
const manifest = (await readJsonFile(rootDirectoryPath, RelativePath.create(PACK_MANIFEST_FILE_NAME), packManifestFileSchema))!
|
||||
|
||||
const metaFiles: MetaFile[] = []
|
||||
const staticSourceFiles: StaticSourceFile[] = []
|
||||
|
||||
const registerSourceFile: Pack["registerCreatedSourceFile"] = async relativePath => {
|
||||
const absolutePath = sourceDirectoryPath.resolve(relativePath)
|
||||
if (!await fs.pathExists(absolutePath.toString())) throw new Error("File does not exist: " + absolutePath)
|
||||
|
||||
const pathSegments = relativePath.toString().split("/")
|
||||
|
||||
const sourceFile: SourceFile = {
|
||||
isStatic: false,
|
||||
isMod: pathSegments[1] === "mods",
|
||||
side: pathSegments[0] as Side,
|
||||
relativePath: relativePath,
|
||||
absolutePath: sourceDirectoryPath.resolve(relativePath),
|
||||
effectivePath: RelativePath._createDirect(pathSegments.slice(1).join("/"))
|
||||
}
|
||||
|
||||
if (relativePath.toString().endsWith("." + META_FILE_EXTENSION)) {
|
||||
const content = (await readSourceJsonFile(relativePath, metaFileContentSchema))!
|
||||
const { source } = content
|
||||
|
||||
const metaFile: MetaFile = {
|
||||
...sourceFile,
|
||||
isStatic: false,
|
||||
effectivePath: sourceFile.effectivePath.parent().joinedWith(content.version.fileName),
|
||||
content,
|
||||
fetchUpdates: source?.type === "modrinth"
|
||||
? allowedReleaseChannels => fetchModrinthModUpdates(metaFile, source, allowedReleaseChannels, manifest.versions.minecraft)
|
||||
: null,
|
||||
async saveContent() {
|
||||
await writeJsonFile(sourceDirectoryPath.resolve(relativePath), metaFileContentSchema, this.content)
|
||||
},
|
||||
getDisplayString: () => `${kleur.yellow(metaFile.relativePath.toString())}${orEmptyString(metaFile.content.displayName, v => " " + kleur.blue(v))}`
|
||||
}
|
||||
|
||||
metaFiles.push(metaFile)
|
||||
} else {
|
||||
staticSourceFiles.push({
|
||||
...sourceFile,
|
||||
isStatic: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFilePaths = await listSourceFiles(sourceDirectoryPath)
|
||||
const limit = createCpuCoreLimiter()
|
||||
await Promise.all(sourceFilePaths.map(path => limit(() => registerSourceFile(path))))
|
||||
|
||||
pack = {
|
||||
paths: {
|
||||
root: rootDirectoryPath,
|
||||
generated: rootDirectoryPath.resolve("generated"),
|
||||
mods: rootDirectoryPath.resolve("mods"),
|
||||
overrides: {
|
||||
client: overridesDirectoryPath.resolve("client"),
|
||||
server: overridesDirectoryPath.resolve("server"),
|
||||
"client-server": overridesDirectoryPath.resolve("client-server")
|
||||
}
|
||||
source: sourceDirectoryPath,
|
||||
exports: rootDirectoryPath.resolve(EXPORTS_DIRECTORY_NAME)
|
||||
},
|
||||
horizrFile: await readHorizrFile(rootDirectoryPath),
|
||||
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
|
||||
const mod: Mod = {
|
||||
id,
|
||||
modFile: (await readModFile(rootDirectoryPath, id))!,
|
||||
async saveModFile() {
|
||||
await writeModFile(rootDirectoryPath, id, this.modFile)
|
||||
},
|
||||
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)
|
||||
manifest,
|
||||
metaFiles,
|
||||
staticSourceFiles,
|
||||
readSourceJsonFile,
|
||||
registerCreatedSourceFile: registerSourceFile,
|
||||
getMetaFile(relativePath: RelativePath) {
|
||||
return metaFiles.find(metaFile => metaFile.relativePath.is(relativePath)) ?? 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
|
||||
}
|
||||
getEffectiveMetaFile(effectivePath: RelativePath, side: Side) {
|
||||
return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? 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 relativeOutputPath = metaFile.effectivePath
|
||||
.parent()
|
||||
.joinedWith(metaFile.absolutePath.getBasename(META_FILE_EXTENSION) + ".toml")
|
||||
|
||||
const outputPath = outputDirectoryPath.resolve(relativeOutputPath)
|
||||
|
||||
await fs.mkdirp(outputPath.parent().toString())
|
||||
await fs.writeFile(outputPath.toString(), content)
|
||||
|
||||
indexedFiles.push({
|
||||
path: relativeOutputPath,
|
||||
isMeta: true,
|
||||
sha512HashHex: await computeSha512HexHash(content)
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath) {
|
||||
const pack = await usePack()
|
||||
|
||||
const index = dedent`
|
||||
hash-format = "sha512"
|
||||
|
||||
${indexedFiles.map(file => dedent`
|
||||
[[files]]
|
||||
file = ${JSON.stringify(file.path.toString())}
|
||||
hash = "${file.sha512HashHex}"
|
||||
metafile = ${file.isMeta}
|
||||
`).join("\n\n")}
|
||||
`
|
||||
|
||||
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
||||
const indexHash = await computeSha512HexHash(index)
|
||||
|
||||
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
|
||||
name = ${JSON.stringify(pack.manifest.meta.name)}
|
||||
author = ${JSON.stringify(pack.manifest.meta.authors.join(", "))}\
|
||||
${orEmptyString(pack.manifest.meta.description, d => `\ndescription = ${JSON.stringify(d)}`)}
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[versions]
|
||||
minecraft = ${JSON.stringify(pack.manifest.versions.minecraft)}
|
||||
fabric = ${JSON.stringify(pack.manifest.versions.fabric)}
|
||||
|
||||
[index]
|
||||
file = "index.toml"
|
||||
hash-format = "sha512"
|
||||
hash = "${indexHash}"
|
||||
`)
|
||||
}
|
61
src/path.ts
61
src/path.ts
|
@ -1,61 +0,0 @@
|
|||
import pathModule from "path"
|
||||
import envPaths from "env-paths"
|
||||
|
||||
export class Path {
|
||||
constructor(private readonly value: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path by resolving the last segment against the other segments, this path and the current working directory.
|
||||
*/
|
||||
resolve(...segments: (string | Path)[]) {
|
||||
return new Path(pathModule.resolve(this.value, ...segments.map(s => s.toString())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new path with this path and the segments joined together.
|
||||
*/
|
||||
join(...segments: (string | Path)[]) {
|
||||
return new Path(pathModule.join(this.value, ...segments.map(s => s.toString())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the relative path from this path to the other path.
|
||||
*/
|
||||
relative(other: Path | string) {
|
||||
return new Path(pathModule.relative(this.value, typeof other === "string" ? other : other.toString()))
|
||||
}
|
||||
|
||||
getParent() {
|
||||
return new Path(pathModule.dirname(this.value))
|
||||
}
|
||||
|
||||
isAbsolute() {
|
||||
return pathModule.isAbsolute(this.value)
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.value
|
||||
}
|
||||
|
||||
static create(...segments: string[]) {
|
||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||
|
||||
return new Path(pathModule.join(...segments))
|
||||
}
|
||||
|
||||
static createAbsolute(...segments: string[]) {
|
||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||
|
||||
return new Path(pathModule.resolve(...segments))
|
||||
}
|
||||
}
|
||||
|
||||
const rawPaths = envPaths("horizr", { suffix: "" })
|
||||
export const paths = {
|
||||
cache: new Path(rawPaths.cache),
|
||||
config: new Path(rawPaths.config),
|
||||
data: new Path(rawPaths.data),
|
||||
log: new Path(rawPaths.log),
|
||||
temp: new Path(rawPaths.temp)
|
||||
}
|
|
@ -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"]
|
117
src/utils.ts
117
src/utils.ts
|
@ -1,117 +0,0 @@
|
|||
import { InvalidArgumentError } from "commander"
|
||||
import hash, { HashaInput } from "hasha"
|
||||
import { Path, paths } from "./path.js"
|
||||
import { ZipFile } from "yazl"
|
||||
import { walk } from "@root/walk"
|
||||
import fs from "fs-extra"
|
||||
import { pEvent } from "p-event"
|
||||
import serveHandler from "serve-handler"
|
||||
import * as http from "http"
|
||||
import addressWithCallback from "address"
|
||||
import { promisify } from "util"
|
||||
import { KeyvFile } from "keyv-file"
|
||||
import originalGot from "got"
|
||||
|
||||
const keyvCache = new KeyvFile({
|
||||
filename: paths.cache.resolve("http.json").toString(),
|
||||
writeDelay: 50,
|
||||
expiredCheckDelay: 24 * 3600 * 1000,
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse
|
||||
})
|
||||
|
||||
export const clearCache = () => keyvCache.clear()
|
||||
|
||||
export const got = originalGot.extend({
|
||||
cache: keyvCache,
|
||||
responseType: "json",
|
||||
headers: {
|
||||
"User-Agent": "moritzruth/horizr/1.0.0 (not yet public)"
|
||||
}
|
||||
})
|
||||
|
||||
const address = promisify(addressWithCallback)
|
||||
export const getLANAddress = () => address().then(r => r.ip)
|
||||
|
||||
export function createSingleConcurrencyWithQueue(fn: () => Promise<void>) {
|
||||
let state: "inactive" | "running_fresh" | "running_old" = "inactive"
|
||||
|
||||
return async () => {
|
||||
if (state === "inactive") {
|
||||
const loop = () => {
|
||||
state = "running_fresh"
|
||||
|
||||
fn().then(() => {
|
||||
if (state === "running_old") loop()
|
||||
})
|
||||
}
|
||||
|
||||
loop()
|
||||
} else {
|
||||
state = "running_old"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) {
|
||||
const server = http.createServer((request, response) => {
|
||||
return serveHandler(request, response, {
|
||||
directoryListing: false,
|
||||
public: path.toString(),
|
||||
cleanUrls: false,
|
||||
headers: [
|
||||
{
|
||||
source: "**/*.toml",
|
||||
headers: [{
|
||||
key: "Content-Type",
|
||||
value: "application/toml"
|
||||
}]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => {
|
||||
onListen()
|
||||
})
|
||||
}
|
||||
|
||||
export async function zipDirectory(directoryPath: Path, outputFilePath: Path) {
|
||||
const zipFile = new ZipFile()
|
||||
zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString()))
|
||||
|
||||
await walk(directoryPath.toString(), async (error, path, dirent) => {
|
||||
if (error) return
|
||||
if (directoryPath.toString() === path) return true
|
||||
if (dirent.name.startsWith(".")) return false
|
||||
|
||||
if (dirent.isFile()) zipFile.addFile(path, directoryPath.relative(path).toString(), { compress: true })
|
||||
})
|
||||
|
||||
zipFile.end()
|
||||
await pEvent(zipFile.outputStream, "close")
|
||||
}
|
||||
|
||||
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
export const getSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" })
|
||||
|
||||
export function truncateWithEllipsis(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength) return text
|
||||
|
||||
return text.slice(0, maxLength - 1).trimEnd() + "…"
|
||||
}
|
||||
|
||||
export function optionParseInteger(value: string): number {
|
||||
const parsed = parseInt(value, 10)
|
||||
if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.")
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function optionParsePositiveInteger(value: string): number {
|
||||
const parsed = parseInt(value, 10)
|
||||
if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.")
|
||||
|
||||
return parsed
|
||||
}
|
16
src/utils/collections.ts
Normal file
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
|
||||
}
|
|
@ -140,14 +140,13 @@ export const output = {
|
|||
}
|
||||
},
|
||||
println(text: string) {
|
||||
this.print(text + "\n")
|
||||
this.print(text + "\n\n")
|
||||
},
|
||||
printlnWrapping(text: string) {
|
||||
this.println(wrapAnsi(text, process.stdout.columns))
|
||||
},
|
||||
warn(text: string) {
|
||||
this.printlnWrapping(`${kleur.yellow(figures.pointer)} ${text}`)
|
||||
this.println("")
|
||||
},
|
||||
fail(text: string) {
|
||||
last(loadersStack)?.fail()
|
131
src/utils/path.ts
Normal file
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(stripExtension?: string): string {
|
||||
return pathModule.basename(this.pathString, "." + stripExtension)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Unnecessary.
|
||||
*/
|
||||
toAbsolute(): AbsolutePath {
|
||||
return this
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.pathString
|
||||
}
|
||||
|
||||
static create(pathString: string) {
|
||||
if (!pathModule.isAbsolute(pathString)) throw new Error("pathString is not absolute")
|
||||
return new AbsolutePath(pathModule.normalize(pathString))
|
||||
}
|
||||
|
||||
static _createDirect(pathString: string) {
|
||||
return new AbsolutePath(pathString)
|
||||
}
|
||||
}
|
||||
|
||||
const rawPaths = getEnvPaths("horizr", { suffix: "" })
|
||||
|
||||
export const envPaths = {
|
||||
cache: AbsolutePath.create(rawPaths.cache),
|
||||
config: AbsolutePath.create(rawPaths.config),
|
||||
data: AbsolutePath.create(rawPaths.data),
|
||||
log: AbsolutePath.create(rawPaths.log),
|
||||
temp: AbsolutePath.create(rawPaths.temp),
|
||||
cwd: AbsolutePath.create(process.cwd())
|
||||
}
|
26
src/utils/promises.ts
Normal file
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")
|
||||
}
|
2
test-pack/.gitignore
vendored
2
test-pack/.gitignore
vendored
|
@ -1 +1 @@
|
|||
/generated/
|
||||
/exports/
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"slug": "test",
|
||||
"meta": {
|
||||
"name": "Test",
|
||||
"version": "1.0.0",
|
||||
|
|
|
@ -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,10 @@
|
|||
{
|
||||
"name": "Sodium",
|
||||
"formatVersion": 1,
|
||||
"enabled": true,
|
||||
"ignoreUpdates": false,
|
||||
"side": "client",
|
||||
"file": {
|
||||
"version": "mc1.18.2-0.4.1",
|
||||
"name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
||||
"version": {
|
||||
"name": "mc1.18.2-0.4.1",
|
||||
"size": 1318645,
|
||||
"fileName": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
||||
"downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar",
|
||||
"hashes": {
|
||||
"sha1": "f839863a6be7014b8d80058ea1f361521148d049",
|
||||
|
@ -16,6 +14,7 @@
|
|||
"source": {
|
||||
"type": "modrinth",
|
||||
"modId": "AANobbMI",
|
||||
"versionId": "74Y5Z8fo"
|
||||
"versionId": "74Y5Z8fo",
|
||||
"ignoreUpdates": false
|
||||
}
|
||||
}
|
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 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"displayName": "Better Leaves",
|
||||
"enabled": true,
|
||||
"version": {
|
||||
"name": "7.0.0",
|
||||
"fileName": "Better-Leaves.zip",
|
||||
"size": 447282,
|
||||
"downloadUrl": "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip",
|
||||
"hashes": {
|
||||
"sha1": "b768ea104fbf268ab69fcdc4004c7f36df15c545",
|
||||
"sha512": "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817"
|
||||
}
|
||||
}
|
||||
}
|
1
test-pack/src/client/resourcepacks/test
Normal file
1
test-pack/src/client/resourcepacks/test
Normal file
|
@ -0,0 +1 @@
|
|||
content
|
20
test-pack/src/server/mods/lithium.hm.json
Normal file
20
test-pack/src/server/mods/lithium.hm.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"enabled": true,
|
||||
"version": {
|
||||
"name": "mc1.18.2-0.7.10",
|
||||
"size": 466196,
|
||||
"fileName": "lithium-fabric-mc1.18.2-0.7.10.jar",
|
||||
"downloadUrl": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.18.2-0.7.10/lithium-fabric-mc1.18.2-0.7.10.jar",
|
||||
"hashes": {
|
||||
"sha1": "d5c19c3d4edb4228652adcc8abb94f9bd80a634c",
|
||||
"sha512": "05f0e51191c9051224c791d63ad4b7915e6f3c442e5d38225e7b05ea4261ee459edb3d8ce99411e1a5a854547549845f21cc8ee2f0079281fec999c1d319fb07"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "modrinth",
|
||||
"modId": "gvQqBUqZ",
|
||||
"versionId": "pHl1Vi6k",
|
||||
"ignoreUpdates": false
|
||||
}
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"formatVersion": 1,
|
||||
"displayName": "Charm",
|
||||
"enabled": true,
|
||||
"ignoreUpdates": false,
|
||||
"side": "client-server",
|
||||
"file": {
|
||||
"version": "4.2.0+1.18.2",
|
||||
"name": "charm-fabric-1.18.2-4.2.0.jar",
|
||||
"version": {
|
||||
"name": "4.2.0+1.18.2",
|
||||
"size": 3413876,
|
||||
"fileName": "charm-fabric-1.18.2-4.2.0.jar",
|
||||
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
|
||||
"hashes": {
|
||||
"sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82",
|
||||
|
@ -16,6 +15,7 @@
|
|||
"source": {
|
||||
"type": "modrinth",
|
||||
"modId": "pOQTcQmj",
|
||||
"versionId": "BT9G1Jjs"
|
||||
"versionId": "BT9G1Jjs",
|
||||
"ignoreUpdates": false
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue