I remembered I should commit every once in a while
This commit is contained in:
parent
fd7e154b5e
commit
585fd43708
52 changed files with 1386 additions and 1035 deletions
|
@ -1,40 +0,0 @@
|
||||||
# horizr
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
**Note:** Most commands are interactive and therefore not suitable for usage in scripts.
|
|
||||||
|
|
||||||
Commands expecting a `MOD_ID` will reuse the ID from the last command if none is provided.
|
|
||||||
|
|
||||||
All commands (aside from `init`) expect to find a `horizr.json` file in their current working directory.
|
|
||||||
|
|
||||||
### init
|
|
||||||
Initialize a new pack in the current working directory.
|
|
||||||
|
|
||||||
### info
|
|
||||||
Print information about the pack.
|
|
||||||
|
|
||||||
### search NAME
|
|
||||||
Search for mods by `NAME` and allow selecting one from the results.
|
|
||||||
|
|
||||||
Selecting a mod has the same effect as the `mod MOD_ID` subcommand.
|
|
||||||
|
|
||||||
### add MOD_ID
|
|
||||||
Adds the mod to the pack.
|
|
||||||
|
|
||||||
### remove MOD_ID
|
|
||||||
Remove the mod from the pack.
|
|
||||||
|
|
||||||
### refresh
|
|
||||||
Fetches information about updates.
|
|
||||||
|
|
||||||
### update MOD_ID
|
|
||||||
Update the mod to a newer version.
|
|
||||||
|
|
||||||
### mod MOD_ID
|
|
||||||
Print information about the mod.
|
|
||||||
|
|
||||||
### export modrinth
|
|
||||||
Export the pack into `./NAME.mrpack` for Modrinth.
|
|
||||||
|
|
||||||
### export packwiz
|
|
||||||
Export the pack into the `./packwiz` directory for packwiz.
|
|
|
@ -24,6 +24,7 @@
|
||||||
"dedent": "^0.7.0",
|
"dedent": "^0.7.0",
|
||||||
"enquirer": "^2.3.6",
|
"enquirer": "^2.3.6",
|
||||||
"env-paths": "^3.0.0",
|
"env-paths": "^3.0.0",
|
||||||
|
"fast-glob": "^3.2.11",
|
||||||
"figures": "^5.0.0",
|
"figures": "^5.0.0",
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
"fs-extra": "^10.1.0",
|
"fs-extra": "^10.1.0",
|
||||||
|
|
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
@ -16,6 +16,7 @@ specifiers:
|
||||||
del-cli: ^5.0.0
|
del-cli: ^5.0.0
|
||||||
enquirer: ^2.3.6
|
enquirer: ^2.3.6
|
||||||
env-paths: ^3.0.0
|
env-paths: ^3.0.0
|
||||||
|
fast-glob: ^3.2.11
|
||||||
figures: ^5.0.0
|
figures: ^5.0.0
|
||||||
find-up: ^6.3.0
|
find-up: ^6.3.0
|
||||||
fs-extra: ^10.1.0
|
fs-extra: ^10.1.0
|
||||||
|
@ -47,6 +48,7 @@ dependencies:
|
||||||
dedent: 0.7.0
|
dedent: 0.7.0
|
||||||
enquirer: 2.3.6
|
enquirer: 2.3.6
|
||||||
env-paths: 3.0.0
|
env-paths: 3.0.0
|
||||||
|
fast-glob: 3.2.11
|
||||||
figures: 5.0.0
|
figures: 5.0.0
|
||||||
find-up: 6.3.0
|
find-up: 6.3.0
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
|
@ -141,12 +143,10 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
run-parallel: 1.2.0
|
run-parallel: 1.2.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@nodelib/fs.stat/2.0.5:
|
/@nodelib/fs.stat/2.0.5:
|
||||||
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@nodelib/fs.walk/1.2.8:
|
/@nodelib/fs.walk/1.2.8:
|
||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
|
@ -154,7 +154,6 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.13.0
|
fastq: 1.13.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@root/walk/1.1.0:
|
/@root/walk/1.1.0:
|
||||||
resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==}
|
resolution: {integrity: sha512-FfXPAta9u2dBuaXhPRawBcijNC9rmKVApmbi6lIZyg36VR/7L02ytxoY5K/14PJlHqiBUoYII73cTlekdKTUOw==}
|
||||||
|
@ -323,7 +322,6 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.0.1
|
fill-range: 7.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/buffer-crc32/0.2.13:
|
/buffer-crc32/0.2.13:
|
||||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||||
|
@ -801,7 +799,6 @@ packages:
|
||||||
glob-parent: 5.1.2
|
glob-parent: 5.1.2
|
||||||
merge2: 1.4.1
|
merge2: 1.4.1
|
||||||
micromatch: 4.0.5
|
micromatch: 4.0.5
|
||||||
dev: true
|
|
||||||
|
|
||||||
/fast-url-parser/1.1.3:
|
/fast-url-parser/1.1.3:
|
||||||
resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==}
|
resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==}
|
||||||
|
@ -813,7 +810,6 @@ packages:
|
||||||
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
|
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.0.4
|
reusify: 1.0.4
|
||||||
dev: true
|
|
||||||
|
|
||||||
/figures/5.0.0:
|
/figures/5.0.0:
|
||||||
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
||||||
|
@ -828,7 +824,6 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/find-up/5.0.0:
|
/find-up/5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
|
@ -905,7 +900,6 @@ packages:
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
dev: true
|
|
||||||
|
|
||||||
/glob/7.2.3:
|
/glob/7.2.3:
|
||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
|
@ -1032,14 +1026,12 @@ packages:
|
||||||
/is-extglob/2.1.1:
|
/is-extglob/2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/is-glob/4.0.3:
|
/is-glob/4.0.3:
|
||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/is-interactive/2.0.0:
|
/is-interactive/2.0.0:
|
||||||
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
|
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
|
||||||
|
@ -1049,7 +1041,6 @@ packages:
|
||||||
/is-number/7.0.0:
|
/is-number/7.0.0:
|
||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
engines: {node: '>=0.12.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/is-path-cwd/3.0.0:
|
/is-path-cwd/3.0.0:
|
||||||
resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==}
|
resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==}
|
||||||
|
@ -1214,7 +1205,6 @@ packages:
|
||||||
/merge2/1.4.1:
|
/merge2/1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/micromatch/4.0.5:
|
/micromatch/4.0.5:
|
||||||
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
|
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
|
||||||
|
@ -1222,7 +1212,6 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.2
|
braces: 3.0.2
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/mime-db/1.33.0:
|
/mime-db/1.33.0:
|
||||||
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
|
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
|
||||||
|
@ -1422,7 +1411,6 @@ packages:
|
||||||
/picomatch/2.3.1:
|
/picomatch/2.3.1:
|
||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/pump/3.0.0:
|
/pump/3.0.0:
|
||||||
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
|
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
|
||||||
|
@ -1437,7 +1425,6 @@ packages:
|
||||||
|
|
||||||
/queue-microtask/1.2.3:
|
/queue-microtask/1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/quick-lru/5.1.1:
|
/quick-lru/5.1.1:
|
||||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||||
|
@ -1505,7 +1492,6 @@ packages:
|
||||||
/reusify/1.0.4:
|
/reusify/1.0.4:
|
||||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/rimraf/3.0.2:
|
/rimraf/3.0.2:
|
||||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||||
|
@ -1518,7 +1504,6 @@ packages:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
dev: true
|
|
||||||
|
|
||||||
/s-ago/2.2.0:
|
/s-ago/2.2.0:
|
||||||
resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==}
|
resolution: {integrity: sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==}
|
||||||
|
@ -1632,7 +1617,6 @@ packages:
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/toml/3.0.0:
|
/toml/3.0.0:
|
||||||
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
|
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
|
||||||
|
|
24
src/commands/info.ts
Normal file
24
src/commands/info.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import { output } from "../utils/output.js"
|
||||||
|
import dedent from "dedent"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { default as wrapAnsi } from "wrap-ansi"
|
||||||
|
import { usePack } from "../pack.js"
|
||||||
|
|
||||||
|
export const infoCommand = new Command("info")
|
||||||
|
.description("Print information about the pack.")
|
||||||
|
.action(async () => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const { meta } = pack.manifest
|
||||||
|
|
||||||
|
output.println(dedent`
|
||||||
|
${kleur.bold(meta.name)} (${meta.version})
|
||||||
|
${meta.description === undefined ? "" : wrapAnsi(meta.description, process.stdout.columns) + "\n"}\
|
||||||
|
|
||||||
|
Authors: ${kleur.yellow(meta.authors.join(", "))}
|
||||||
|
License: ${kleur.yellow(meta.license.toUpperCase())}
|
||||||
|
Mods: ${kleur.yellow(pack.metaFiles.filter(metaFile => metaFile.isMod).length.toString())}
|
||||||
|
|
||||||
|
Minecraft version: ${kleur.yellow(pack.manifest.versions.minecraft)}
|
||||||
|
`)
|
||||||
|
})
|
82
src/commands/init.ts
Normal file
82
src/commands/init.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import { envPaths } from "../utils/path.js"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { output } from "../utils/output.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "../fabricApi.js"
|
||||||
|
import enquirer from "enquirer"
|
||||||
|
import { PACK_MANIFEST_FILE_NAME, PACK_MANIFEST_FORMAT_VERSION, PackManifest } from "../files.js"
|
||||||
|
import pathModule from "path"
|
||||||
|
|
||||||
|
export const initCommand = new Command("init")
|
||||||
|
.argument("<path>")
|
||||||
|
.description("Initialize a new pack in the directory.")
|
||||||
|
.action(async pathString => {
|
||||||
|
const path = envPaths.cwd.resolveAny(pathString)
|
||||||
|
const manifestFilePath = path.resolve(PACK_MANIFEST_FILE_NAME)
|
||||||
|
|
||||||
|
if (await fs.pathExists(manifestFilePath.toString())) output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} already exists in the directory.`)
|
||||||
|
|
||||||
|
await fs.mkdirp(path.toString())
|
||||||
|
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
|
||||||
|
|
||||||
|
const answers: any = await enquirer.prompt([
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "input",
|
||||||
|
message: "Name",
|
||||||
|
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "authors",
|
||||||
|
type: "input",
|
||||||
|
message: "Authors (comma-separated)",
|
||||||
|
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
type: "text",
|
||||||
|
message: "Description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "license",
|
||||||
|
type: "text",
|
||||||
|
message: "License (SPDX-ID)",
|
||||||
|
validate: answer => answer.length === 0 ? "An answer is required." : true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minecraftVersion",
|
||||||
|
type: "autocomplete",
|
||||||
|
message: "Minecraft version",
|
||||||
|
choices: minecraftVersions.map(version => ({
|
||||||
|
name: version,
|
||||||
|
value: version
|
||||||
|
})),
|
||||||
|
// @ts-expect-error
|
||||||
|
limit: 10,
|
||||||
|
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
|
||||||
|
|
||||||
|
const file: PackManifest = {
|
||||||
|
formatVersion: PACK_MANIFEST_FORMAT_VERSION,
|
||||||
|
meta: {
|
||||||
|
name: answers.name,
|
||||||
|
version: "1.0.0",
|
||||||
|
description: answers.description === "" ? undefined : answers.description,
|
||||||
|
authors: (answers.authors as string).split(", ").map(a => a.trim()),
|
||||||
|
license: answers.license
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
minecraft: answers.minecraftVersion,
|
||||||
|
fabric: fabricVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeJson(manifestFilePath.toString(), file, { spaces: 2 })
|
||||||
|
await fs.writeFile(path.resolve(".gitignore").toString(), "/generated/")
|
||||||
|
|
||||||
|
output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(pathModule.normalize(pathString))}`))
|
||||||
|
})
|
82
src/commands/modrinth/activate.ts
Normal file
82
src/commands/modrinth/activate.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import { sideOption } from "../../utils/options.js"
|
||||||
|
import {
|
||||||
|
findMetaFileForModrinthMod,
|
||||||
|
getMetaFileContentVersionForModrinth,
|
||||||
|
getSideOfModrinthMod,
|
||||||
|
isModrinthVersionCompatible,
|
||||||
|
resolveModrinthCode,
|
||||||
|
sortModrinthVersionsByPreference
|
||||||
|
} from "../../modrinth/index.js"
|
||||||
|
import { output } from "../../utils/output.js"
|
||||||
|
import { modrinthApi } from "../../modrinth/api.js"
|
||||||
|
import { Side, usePack } from "../../pack.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { META_FILE_EXTENSION, metaFileContentSchema, writeJsonFile } from "../../files.js"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import enquirer from "enquirer"
|
||||||
|
|
||||||
|
export const activateCommand = new Command("activate")
|
||||||
|
.argument("<code>")
|
||||||
|
.alias("a")
|
||||||
|
.option("-s, --side <side>", "The side of the mod", sideOption, null)
|
||||||
|
.option("-y, --yes", "Skip confirmations.")
|
||||||
|
.action(async (code, options) => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const resolvedCode = await output.withLoading(resolveModrinthCode(code), "Resolving code")
|
||||||
|
const modrinthMod = resolvedCode.modrinthMod
|
||||||
|
let modrinthVersion = resolvedCode.modrinthVersion
|
||||||
|
|
||||||
|
const existingMetaFile = findMetaFileForModrinthMod(pack.metaFiles, modrinthMod)
|
||||||
|
if (existingMetaFile !== null) {
|
||||||
|
output.println(`The mod is already active: ${kleur.yellow(existingMetaFile.relativePath.toString())} ${kleur.blue(existingMetaFile.content.version.name)}`)
|
||||||
|
|
||||||
|
const confirmed = options.yes || (await enquirer.prompt({
|
||||||
|
type: "confirm",
|
||||||
|
name: "confirmed",
|
||||||
|
message: "Do you want to continue?",
|
||||||
|
initial: false
|
||||||
|
}) as any).confirmed
|
||||||
|
|
||||||
|
if (!confirmed) process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
let side: Side
|
||||||
|
const specifiedSide = getSideOfModrinthMod(modrinthMod)
|
||||||
|
const sideOverride = options.side
|
||||||
|
|
||||||
|
if (sideOverride === null) side = specifiedSide
|
||||||
|
else {
|
||||||
|
if (specifiedSide !== "universal" && specifiedSide !== sideOverride) return output.failAndExit(`Mod is incompatible with specified side: ${kleur.yellow(sideOverride)}`)
|
||||||
|
else side = sideOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modrinthVersion === null) {
|
||||||
|
const versions = await output.withLoading(modrinthApi.listVersions(modrinthMod.id, pack.manifest.versions.minecraft), "Fetching versions")
|
||||||
|
if (versions.length === 0) return output.failAndExit("No compatible version available.")
|
||||||
|
|
||||||
|
const sortedVersions = sortModrinthVersionsByPreference(versions)
|
||||||
|
modrinthVersion = sortedVersions[0]
|
||||||
|
} else {
|
||||||
|
if (!isModrinthVersionCompatible(modrinthVersion, pack)) return output.failAndExit("This version is not compatible with the pack.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = pack.paths.source.resolve(side, "mods", `${modrinthMod.slug}.${META_FILE_EXTENSION}`)
|
||||||
|
const relativePath = pack.paths.source.relativeTo(absolutePath)
|
||||||
|
|
||||||
|
await fs.mkdirp(absolutePath.parent().toString())
|
||||||
|
await writeJsonFile(absolutePath, metaFileContentSchema, {
|
||||||
|
enabled: true,
|
||||||
|
version: getMetaFileContentVersionForModrinth(modrinthVersion),
|
||||||
|
source: {
|
||||||
|
type: "modrinth",
|
||||||
|
versionId: modrinthVersion.id,
|
||||||
|
modId: modrinthVersion.projectId,
|
||||||
|
ignoreUpdates: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await pack.registerCreatedSourceFile(relativePath)
|
||||||
|
|
||||||
|
output.println(kleur.green(`Successfully wrote ${kleur.yellow(relativePath.toString())}`))
|
||||||
|
})
|
16
src/commands/modrinth/index.ts
Normal file
16
src/commands/modrinth/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import { activateCommand } from "./activate.js"
|
||||||
|
import dedent from "dedent"
|
||||||
|
import kleur from "kleur"
|
||||||
|
|
||||||
|
export const modrinthCommand = new Command("modrinth")
|
||||||
|
.alias("mr")
|
||||||
|
.addCommand(activateCommand)
|
||||||
|
.addHelpText("after", dedent`
|
||||||
|
${kleur.yellow("<code>")} may be one of the following:
|
||||||
|
- URL or slug of a Modrinth mod (${kleur.yellow("https://modrinth.com/mod/sodium")} or ${kleur.yellow("sodium")})
|
||||||
|
- URL of a Modrinth mod version (${kleur.yellow("https://modrinth.com/mod/sodium/version/mc1.19-0.4.2")})
|
||||||
|
- slug of a Modrinth mod and a version with a ${kleur.yellow("@")} in between (${kleur.yellow("sodium:mc1.19-0.4.2")})
|
||||||
|
- Modrinth project ID (${kleur.yellow("AANobbMI")} for Sodium)
|
||||||
|
- Modrinth version ID, prefixed with ${kleur.yellow("@")} (${kleur.yellow("@Yp8wLY1P")} for Sodium mc1.19-0.4.2)
|
||||||
|
`)
|
|
@ -1,229 +0,0 @@
|
||||||
import { Command } from "commander"
|
|
||||||
import { Mod, usePack } from "../pack.js"
|
|
||||||
import fs from "fs-extra"
|
|
||||||
import dedent from "dedent"
|
|
||||||
import kleur from "kleur"
|
|
||||||
import { getLANAddress, getSha512HexHash, httpServeDirectory, optionParsePositiveInteger } from "../utils.js"
|
|
||||||
import { output } from "../output.js"
|
|
||||||
import { Visitor, walk } from "@root/walk"
|
|
||||||
import { Path } from "../path.js"
|
|
||||||
import toml from "toml"
|
|
||||||
import { addModrinthMod } from "../modrinth/utils.js"
|
|
||||||
import { modrinthApi } from "../modrinth/api.js"
|
|
||||||
|
|
||||||
const packwizCommand = new Command("packwiz")
|
|
||||||
.alias("pw")
|
|
||||||
|
|
||||||
packwizCommand.command("import <path>")
|
|
||||||
.description("Import the mods of a packwiz pack. Overrides are ignored.")
|
|
||||||
.addHelpText("after", kleur.red("This command should only be used in newly created packs. Otherwise, the behaviour is undefined."))
|
|
||||||
.action(async path => {
|
|
||||||
const packDirectoryPath = Path.create(path)
|
|
||||||
const modsDirectoryPath = packDirectoryPath.resolve("mods")
|
|
||||||
if (!await fs.pathExists(modsDirectoryPath.toString())) output.failAndExit(`The pack does not contain a ${kleur.yellow("mods")} directory.`)
|
|
||||||
|
|
||||||
const modFileNames = (await fs.readdir(modsDirectoryPath.toString(), { withFileTypes: true }))
|
|
||||||
.filter(dirent => dirent.isFile() && dirent.name.endsWith(".toml"))
|
|
||||||
.map(dirent => dirent.name)
|
|
||||||
|
|
||||||
let index = 0
|
|
||||||
for (const modFileName of modFileNames) {
|
|
||||||
const content = toml.parse(await fs.readFile(modsDirectoryPath.resolve(modFileName).toString(), "utf-8"))
|
|
||||||
const modrinthVersionId = content.update?.modrinth?.version
|
|
||||||
|
|
||||||
if (modrinthVersionId === undefined) output.warn(`${kleur.yellow(modFileName)} has no Modrinth version ID associated. It will not be imported.`)
|
|
||||||
else {
|
|
||||||
const modrinthVersion = (await output.withLoading(modrinthApi.getVersion(modrinthVersionId), "Fetching version information"))!
|
|
||||||
const modrinthMod = (await output.withLoading(modrinthApi.getMod(modrinthVersion.projectId), "Fetching mod information"))!
|
|
||||||
|
|
||||||
await addModrinthMod(modrinthMod, modrinthVersion, content.side?.replace("both", "client-server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
output.println(`${kleur.yellow(modFileName)} ${kleur.green("was imported.")} ${kleur.gray(`(${index}/${modFileNames.length})`)}`)
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
|
|
||||||
output.println(`${kleur.yellow(modFileNames.length)} ${kleur.green("mods were imported.")}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
packwizCommand.command("serve")
|
|
||||||
.description("Start an HTTP server in the packwiz directory.")
|
|
||||||
.option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
|
|
||||||
.option("-e, --expose", "Expose the HTTP server on all interfaces.")
|
|
||||||
.action(async options => {
|
|
||||||
const pack = await usePack()
|
|
||||||
const directoryPath = pack.paths.generated.resolve("packwiz")
|
|
||||||
if (!(await fs.pathExists(directoryPath.toString())))
|
|
||||||
output.failAndExit(`The ${kleur.yellow("packwiz")} directory does not exist. Generate it by running ${kleur.yellow("horizr packwiz export")}.`)
|
|
||||||
|
|
||||||
await serveExportOutput(directoryPath, options.port, options.expose)
|
|
||||||
})
|
|
||||||
|
|
||||||
// packwizCommand.command("dev")
|
|
||||||
// .description("serve + export with hot-reloading.")
|
|
||||||
// .option("-s, --server", "Use server overrides instead of client overrides.")
|
|
||||||
// .option("-p, --port <port>", "The port of the HTTP server.", optionParsePositiveInteger, 8000)
|
|
||||||
// .option("-e, --expose", "Expose the HTTP server on all interfaces.")
|
|
||||||
// .action(async options => {
|
|
||||||
//
|
|
||||||
// })
|
|
||||||
|
|
||||||
packwizCommand.command("export")
|
|
||||||
.description("Generate a packwiz pack in the packwiz directory.")
|
|
||||||
.option("-s, --server", "Use server overrides instead of client overrides.")
|
|
||||||
.action(async options => {
|
|
||||||
await runExport(options.server)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function runExport(forServer: boolean) {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
const loader = output.startLoading("Generating")
|
|
||||||
|
|
||||||
const outputDirectoryPath = pack.paths.generated.resolve("packwiz")
|
|
||||||
await fs.remove(outputDirectoryPath.toString())
|
|
||||||
await fs.mkdirp(outputDirectoryPath.resolve("mods").toString())
|
|
||||||
|
|
||||||
const indexedFiles: IndexedFile[] = []
|
|
||||||
await indexMods(indexedFiles, outputDirectoryPath)
|
|
||||||
|
|
||||||
loader.setText(`Copying and hashing ${forServer ? "server" : "client"} overrides`)
|
|
||||||
await copyOverrides(indexedFiles, outputDirectoryPath, forServer)
|
|
||||||
|
|
||||||
loader.setText(`Writing ${kleur.yellow("index.toml")}`)
|
|
||||||
|
|
||||||
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
|
|
||||||
|
|
||||||
loader.stop()
|
|
||||||
output.println(kleur.green("Generated packwiz pack"))
|
|
||||||
|
|
||||||
return {
|
|
||||||
indexedFiles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IndexedFile {
|
|
||||||
path: string
|
|
||||||
sha512HashHex: string
|
|
||||||
isMeta: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeAndIndexModMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: Path, mod: Mod) {
|
|
||||||
const content = dedent`
|
|
||||||
name = ${JSON.stringify(mod.modFile.name)}
|
|
||||||
filename = ${JSON.stringify(mod.modFile.file.name)}
|
|
||||||
side = "${mod.modFile.side.replace("client-server", "both")}"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
hash-format = "sha512"
|
|
||||||
hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)}
|
|
||||||
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
|
|
||||||
`
|
|
||||||
|
|
||||||
const path = outputDirectoryPath.resolve(`mods/${mod.id}.toml`)
|
|
||||||
await fs.writeFile(path.toString(), content)
|
|
||||||
|
|
||||||
indexedFiles.push({
|
|
||||||
path: `mods/${mod.id}.toml`,
|
|
||||||
isMeta: true,
|
|
||||||
sha512HashHex: await getSha512HexHash(content)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function indexMods(indexedFiles: IndexedFile[], outputDirectoryPath: Path, warn: boolean = true) {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
for (const mod of pack.mods) {
|
|
||||||
if (warn && !mod.modFile.enabled) output.warn(`${kleur.yellow(mod.modFile.name)} is disabled and will not be included.`)
|
|
||||||
|
|
||||||
await output.withLoading(
|
|
||||||
writeAndIndexModMetaFile(indexedFiles, outputDirectoryPath, mod),
|
|
||||||
`Generating ${kleur.yellow(mod.id + ".toml")} (${indexedFiles.length + 1}/${pack.mods.length})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyOverrides(indexedFiles: IndexedFile[], outputDirectoryPath:Path, forServer: boolean) {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
const createVisitor = (overridesDirectoryPath: Path): Visitor => async (error, path, dirent) => {
|
|
||||||
const relativePath = overridesDirectoryPath.relative(path)
|
|
||||||
|
|
||||||
if (error) output.warn(`${kleur.yellow(relativePath.toString())}: ${error.message}`)
|
|
||||||
else {
|
|
||||||
if (dirent.name.startsWith(".")) return false
|
|
||||||
if (dirent.isFile()) {
|
|
||||||
const outputPath = outputDirectoryPath.resolve(relativePath)
|
|
||||||
await fs.mkdirp(outputPath.getParent().toString())
|
|
||||||
await fs.copy(path, outputPath.toString())
|
|
||||||
|
|
||||||
indexedFiles.push({
|
|
||||||
path: relativePath.toString(),
|
|
||||||
isMeta: false,
|
|
||||||
sha512HashHex: await getSha512HexHash(await fs.readFile(overridesDirectoryPath.resolve(path).toString()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const specificOverridesDirectoryPath = pack.paths.overrides[forServer ? "server" : "client"]
|
|
||||||
const universalOverridesDirectoryPath = pack.paths.overrides["client-server"]
|
|
||||||
|
|
||||||
if (await fs.pathExists(specificOverridesDirectoryPath.toString())) await walk(specificOverridesDirectoryPath.toString(), createVisitor(specificOverridesDirectoryPath))
|
|
||||||
if (await fs.pathExists(universalOverridesDirectoryPath.toString())) await walk(universalOverridesDirectoryPath.toString(), createVisitor(universalOverridesDirectoryPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: Path) {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
const index = dedent`
|
|
||||||
hash-format = "sha512"
|
|
||||||
|
|
||||||
${indexedFiles.map(file => dedent`
|
|
||||||
[[files]]
|
|
||||||
file = ${JSON.stringify(file.path)}
|
|
||||||
hash = "${file.sha512HashHex}"
|
|
||||||
metafile = ${file.isMeta}
|
|
||||||
`).join("\n\n")}
|
|
||||||
`
|
|
||||||
|
|
||||||
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
|
||||||
const indexHash = await getSha512HexHash(index)
|
|
||||||
|
|
||||||
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
|
|
||||||
name = ${JSON.stringify(pack.horizrFile.meta.name)}
|
|
||||||
authors = ${JSON.stringify(pack.horizrFile.meta.authors.join(", "))}\
|
|
||||||
${pack.horizrFile.meta.description === undefined ? "" : "\n" + `description = ${JSON.stringify(pack.horizrFile.meta.description)}`}
|
|
||||||
pack-format = "packwiz:1.0.0"
|
|
||||||
|
|
||||||
[versions]
|
|
||||||
minecraft = ${JSON.stringify(pack.horizrFile.versions.minecraft)}
|
|
||||||
fabric = ${JSON.stringify(pack.horizrFile.versions.fabric)}
|
|
||||||
|
|
||||||
[index]
|
|
||||||
file = "index.toml"
|
|
||||||
hash-format = "sha512"
|
|
||||||
hash = "${indexHash}"
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serveExportOutput(path: Path, port: number, expose: boolean) {
|
|
||||||
const lanAddress = await getLANAddress()
|
|
||||||
const localAddress = `http://localhost:${port}/pack.toml`
|
|
||||||
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
httpServeDirectory(path, port, expose, () => {
|
|
||||||
if (expose) {
|
|
||||||
output.println(dedent`
|
|
||||||
${kleur.green("Serving at")}
|
|
||||||
Local: ${kleur.yellow(localAddress)}
|
|
||||||
Network: ${kleur.yellow(`http://${lanAddress}:${port}/pack.toml`)}
|
|
||||||
`)
|
|
||||||
} else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`)
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { packwizCommand}
|
|
42
src/commands/packwiz/export.ts
Normal file
42
src/commands/packwiz/export.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { Side, usePack } from "../../pack.js"
|
||||||
|
import { output } from "../../utils/output.js"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { IndexedFile, PACKWIZ_EXPORT_DIRECTORY_NAME, writeAndIndexMetaFile, writeAndIndexStaticSourceFile, writeIndexAndPackManifest } from "../../packwiz/exporting.js"
|
||||||
|
|
||||||
|
export const exportCommand = new Command("export")
|
||||||
|
.description("Export a packwiz pack.")
|
||||||
|
.option("-s, --server", "Use server overrides instead of client overrides. Only applies to static files.")
|
||||||
|
.action(async (path, options) => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const side: Side = options.server ? "server" : "client"
|
||||||
|
const loader = output.startLoading("Exporting")
|
||||||
|
|
||||||
|
const outputDirectoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME)
|
||||||
|
await fs.remove(outputDirectoryPath.toString())
|
||||||
|
|
||||||
|
const indexedFiles: IndexedFile[] = []
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
for (const metaFile of pack.metaFiles) {
|
||||||
|
i++
|
||||||
|
loader.setText(`Exporting ${kleur.yellow(metaFile.getDisplayString())} (${i}/${pack.metaFiles.length})`)
|
||||||
|
await writeAndIndexMetaFile(indexedFiles, outputDirectoryPath, metaFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
for (const staticSourceFile of pack.staticSourceFiles) {
|
||||||
|
i++
|
||||||
|
if (staticSourceFile.side !== "universal" && staticSourceFile.side !== side) continue
|
||||||
|
|
||||||
|
loader.setText(`Exporting ${kleur.yellow(staticSourceFile.relativePath.toString())} (${i}/${pack.metaFiles.length})`)
|
||||||
|
await writeAndIndexStaticSourceFile(indexedFiles, outputDirectoryPath, staticSourceFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
loader.setText(`Creating ${kleur.yellow("index.toml")} and ${kleur.yellow("pack.toml")}`)
|
||||||
|
await writeIndexAndPackManifest(indexedFiles, outputDirectoryPath)
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
output.println(kleur.green("Generated packwiz pack"))
|
||||||
|
})
|
22
src/commands/packwiz/import.ts
Normal file
22
src/commands/packwiz/import.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { envPaths } from "../../utils/path.js"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { output } from "../../utils/output.js"
|
||||||
|
import * as toml from "toml"
|
||||||
|
|
||||||
|
export const importCommand = new Command("import")
|
||||||
|
.argument("<path>")
|
||||||
|
.description("Import a packwiz pack.")
|
||||||
|
.addHelpText("after", kleur.red("Please create a backup of the pack before using this command."))
|
||||||
|
.action(async path => {
|
||||||
|
const inputDirectoryPath = envPaths.cwd.resolveAny(path)
|
||||||
|
const packTomlPath = inputDirectoryPath.resolve("pack.toml")
|
||||||
|
|
||||||
|
if (!await fs.pathExists(packTomlPath.toString()))
|
||||||
|
output.failAndExit(`${kleur.yellow(packTomlPath.toString())} does not exist.`)
|
||||||
|
|
||||||
|
const packTomlContent = toml.parse(await fs.readFile(packTomlPath.toString(), "utf-8"))
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
})
|
10
src/commands/packwiz/index.ts
Normal file
10
src/commands/packwiz/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
// import { importCommand } from "./import.js"
|
||||||
|
import { serveCommand } from "./serve.js"
|
||||||
|
import { exportCommand } from "./export.js"
|
||||||
|
|
||||||
|
export const packwizCommand = new Command("packwiz")
|
||||||
|
.alias("pw")
|
||||||
|
.addCommand(exportCommand)
|
||||||
|
// .addCommand(importCommand)
|
||||||
|
.addCommand(serveCommand)
|
24
src/commands/packwiz/serve.ts
Normal file
24
src/commands/packwiz/serve.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { output } from "../../utils/output.js"
|
||||||
|
import { positiveIntegerOption } from "../../utils/options.js"
|
||||||
|
import { usePack } from "../../pack.js"
|
||||||
|
import { PACKWIZ_EXPORT_DIRECTORY_NAME } from "../../packwiz/exporting.js"
|
||||||
|
import { httpServeDirectoryWithMessage } from "../../utils/http.js"
|
||||||
|
|
||||||
|
export const serveCommand = new Command("serve")
|
||||||
|
.description("Start an HTTP server in the packwiz directory.")
|
||||||
|
.option("-p, --port <port>", "The port of the HTTP server.", positiveIntegerOption, 8000)
|
||||||
|
.option("-e, --expose", "Expose the HTTP server on all interfaces.")
|
||||||
|
.action(async options => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const directoryPath = pack.paths.exports.resolve(PACKWIZ_EXPORT_DIRECTORY_NAME)
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(directoryPath.toString())))
|
||||||
|
output.failAndExit(`The ${kleur.yellow(pack.paths.root.relativeTo(directoryPath).toString())} directory does not exist. ` +
|
||||||
|
`Generate it by running ${kleur.yellow("horizr packwiz export")}.`
|
||||||
|
)
|
||||||
|
|
||||||
|
await httpServeDirectoryWithMessage(directoryPath, options.port, options.expose)
|
||||||
|
})
|
82
src/commands/update.ts
Normal file
82
src/commands/update.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Command } from "commander"
|
||||||
|
import { output } from "../utils/output.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import dedent from "dedent"
|
||||||
|
import figures from "figures"
|
||||||
|
import { ReleaseChannel, Update, usePack } from "../pack.js"
|
||||||
|
import { filterNulls, mapNotNull } from "../utils/collections.js"
|
||||||
|
import pLimit from "p-limit"
|
||||||
|
import { gtzIntegerOption } from "../utils/options.js"
|
||||||
|
import enquirer from "enquirer"
|
||||||
|
|
||||||
|
export const updateCommand = new Command("update")
|
||||||
|
.argument("[path]")
|
||||||
|
.description("Check for updates of all meta files or apply a specific update.")
|
||||||
|
.option("-y, --yes", "Skip confirmations")
|
||||||
|
.option("-a, --alpha", "Allow alpha versions")
|
||||||
|
.option("-b, --beta", "Allow beta versions")
|
||||||
|
.option("-c, --concurrency", "Number of concurrent checks", gtzIntegerOption, 5)
|
||||||
|
.action(async (pathString, options) => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const allowedReleaseChannels: ReleaseChannel[] = ["release"]
|
||||||
|
if (options.alpha) allowedReleaseChannels.push("alpha")
|
||||||
|
if (options.beta) allowedReleaseChannels.push("beta")
|
||||||
|
|
||||||
|
if (pathString === undefined) {
|
||||||
|
const limit = pLimit(options.concurrency)
|
||||||
|
const updateFetches = mapNotNull(pack.metaFiles, metaFile => {
|
||||||
|
const { fetchUpdates } = metaFile
|
||||||
|
if (fetchUpdates === null) return null
|
||||||
|
else return limit(async () => {
|
||||||
|
const updates = await fetchUpdates(allowedReleaseChannels)
|
||||||
|
if (updates.length === 0) return null
|
||||||
|
else return updates[0]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const updates = filterNulls(
|
||||||
|
await output.withLoading(
|
||||||
|
Promise.all(updateFetches),
|
||||||
|
`Fetching updates for ${kleur.yellow(updateFetches.length)} meta files`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updates.length === 0) output.println(kleur.green("Everything up-to-date."))
|
||||||
|
else {
|
||||||
|
const getChange = (update: Update) => `${kleur.red(update.of.content.version.name)} ${figures.arrowRight} ${kleur.green(update.versionString)}`
|
||||||
|
|
||||||
|
output.println(dedent`
|
||||||
|
${kleur.underline("Available updates")}
|
||||||
|
|
||||||
|
${updates.map(update => `- ${update.of.getDisplayString()} ${getChange(update)}`).join("\n")}
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const metaFile = pack.getMetaFileFromInput(pathString)
|
||||||
|
if (metaFile.fetchUpdates === null) return output.failAndExit(`${kleur.yellow(metaFile.relativePath.toString())} is not updatable.`)
|
||||||
|
|
||||||
|
const updates = await metaFile.fetchUpdates(allowedReleaseChannels)
|
||||||
|
if (updates.length === 0) output.println(kleur.green("No updates available."))
|
||||||
|
else {
|
||||||
|
output.println(kleur.bold("Changelogs") + "\n")
|
||||||
|
|
||||||
|
for (let update of updates) {
|
||||||
|
output.println(kleur.underline(update.versionString))
|
||||||
|
output.printlnWrapping((update.changelog ?? kleur.gray("not provided")) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = options.yes || (await enquirer.prompt({
|
||||||
|
type: "confirm",
|
||||||
|
name: "confirmed",
|
||||||
|
message: "Apply the update?"
|
||||||
|
}) as any).confirmed
|
||||||
|
|
||||||
|
const update = updates[0]
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
await output.withLoading(update.apply(), "Updating")
|
||||||
|
output.println(kleur.green(`Successfully updated ${metaFile.getDisplayString()} to ${kleur.yellow(update.versionString)}.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -1,4 +1,4 @@
|
||||||
import { got } from "./utils.js"
|
import { got } from "./utils/http.js"
|
||||||
|
|
||||||
export async function fetchFabricMinecraftVersions(): Promise<string[]> {
|
export async function fetchFabricMinecraftVersions(): Promise<string[]> {
|
||||||
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()
|
const versions = await got("https://meta.fabricmc.net/v1/versions/game").json<any[]>()
|
||||||
|
|
126
src/files.ts
126
src/files.ts
|
@ -1,57 +1,54 @@
|
||||||
import { SafeParseError, z, ZodRawShape } from "zod"
|
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||||
import kleur from "kleur"
|
import process from "process"
|
||||||
import fs from "fs-extra"
|
|
||||||
import * as process from "process"
|
|
||||||
import { dirname } from "path"
|
|
||||||
import { findUp } from "find-up"
|
import { findUp } from "find-up"
|
||||||
import { output } from "./output.js"
|
import { output } from "./utils/output.js"
|
||||||
import { Path } from "./path.js"
|
import kleur from "kleur"
|
||||||
import { Dirent } from "fs"
|
import { SafeParseError, z, ZodRawShape } from "zod"
|
||||||
import { sides } from "./shared.js"
|
import fs from "fs-extra"
|
||||||
|
import { dirname } from "path"
|
||||||
|
import fastGlob from "fast-glob"
|
||||||
|
import { sides } from "./pack.js"
|
||||||
|
|
||||||
export async function findPackDirectoryPath(): Promise<Path> {
|
export async function findPackDirectoryPath(): Promise<AbsolutePath> {
|
||||||
if (process.argv0.endsWith("/node")) { // run using pnpm
|
if (process.argv0.endsWith("/node")) { // run using pnpm
|
||||||
return Path.createAbsolute("./test-pack")
|
return envPaths.cwd.resolve("./test-pack")
|
||||||
} else {
|
} else {
|
||||||
const parent = await findUp("horizr.json")
|
const parent = await findUp(PACK_MANIFEST_FILE_NAME)
|
||||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`)
|
if (parent === undefined) return output.failAndExit(`${kleur.yellow(PACK_MANIFEST_FILE_NAME)} could not be found in the current working directory or any parent.`)
|
||||||
|
|
||||||
return Path.createAbsolute(dirname(parent))
|
return AbsolutePath.create(dirname(parent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
export async function writeJsonFile<S extends z.ZodObject<ZodRawShape>>(path: AbsolutePath, schema: S, data: z.input<S>) {
|
||||||
packPath: Path,
|
await fs.mkdirp(path.parent().toString())
|
||||||
filePath: Path,
|
await fs.writeJson(path.toString(), schema.parse(data), { spaces: 2 })
|
||||||
schema: S
|
}
|
||||||
): Promise<z.output<S> | null> {
|
|
||||||
|
export async function readJsonFile<S extends z.ZodObject<ZodRawShape>>(rootPath: AbsolutePath, specificPath: RelativePath, schema: S): Promise<z.output<S> | null> {
|
||||||
let data
|
let data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await fs.readJson(packPath.resolve(filePath).toString())
|
data = await fs.readJson(rootPath.resolve(specificPath).toString())
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`)
|
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(specificPath.toString())} does not contain valid JSON.`)
|
||||||
else return null
|
else return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await schema.safeParseAsync(data)
|
const result = await schema.safeParseAsync(data)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const error = (result as SafeParseError<unknown>).error
|
const error = (result as SafeParseError<unknown>).error
|
||||||
return output.failAndExit(`${kleur.yellow(filePath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
return output.failAndExit(`${kleur.yellow(specificPath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) {
|
export const PACK_MANIFEST_FORMAT_VERSION = 1
|
||||||
const absolutePath = packPath.resolve(filePath)
|
export const PACK_MANIFEST_FILE_NAME = "horizr.json"
|
||||||
await fs.mkdirp(absolutePath.getParent().toString())
|
|
||||||
|
|
||||||
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
|
export const horizrFileSchema = z.object({
|
||||||
}
|
formatVersion: z.literal(PACK_MANIFEST_FORMAT_VERSION),
|
||||||
|
|
||||||
const horizrFileSchema = z.object({
|
|
||||||
formatVersion: z.string().or(z.number()),
|
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
|
@ -65,75 +62,48 @@ const horizrFileSchema = z.object({
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export type HorizrFile = z.output<typeof horizrFileSchema>
|
export type PackManifest = z.output<typeof horizrFileSchema>
|
||||||
export const CURRENT_HORIZR_FILE_FORMAT_VERSION = 1
|
|
||||||
|
|
||||||
export async function readHorizrFile(packPath: Path) {
|
export const META_FILE_EXTENSION = "hm.json"
|
||||||
const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema)
|
|
||||||
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
|
||||||
if (data.formatVersion !== CURRENT_HORIZR_FILE_FORMAT_VERSION) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
|
||||||
|
|
||||||
return data
|
const metaFileModrinthSourceSchema = z.object({
|
||||||
}
|
|
||||||
|
|
||||||
const modFileModrinthSourceSchema = z.object({
|
|
||||||
type: z.literal("modrinth"),
|
type: z.literal("modrinth"),
|
||||||
modId: z.string(),
|
modId: z.string(),
|
||||||
versionId: z.string()
|
versionId: z.string()
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ModFileModrinthSource = z.output<typeof modFileModrinthSourceSchema>
|
export type MetaFileModrinthSource = z.output<typeof metaFileModrinthSourceSchema>
|
||||||
|
|
||||||
const modFileDataSchema = z.object({
|
const metaFileContentVersionSchema = z.object({
|
||||||
version: z.string(),
|
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().int().min(0).optional(),
|
size: z.number().int().min(0).optional(),
|
||||||
|
fileName: z.string(),
|
||||||
downloadUrl: z.string().url(),
|
downloadUrl: z.string().url(),
|
||||||
hashes: z.object({ // Adopted from Modrinth
|
hashes: z.object({
|
||||||
sha1: z.string(),
|
sha1: z.string(),
|
||||||
sha512: z.string()
|
sha512: z.string()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ModFileData = z.output<typeof modFileDataSchema>
|
export type MetaFileContentVersion = z.output<typeof metaFileContentVersionSchema>
|
||||||
|
|
||||||
const modFileSchema = z.object({
|
export const metaFileContentSchema = z.object({
|
||||||
name: z.string(),
|
displayName: z.string().optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
ignoreUpdates: z.boolean().default(false),
|
|
||||||
side: z.enum(sides),
|
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
file: modFileDataSchema,
|
version: metaFileContentVersionSchema,
|
||||||
source: z.discriminatedUnion("type", [
|
source: z.discriminatedUnion("type", [
|
||||||
modFileModrinthSourceSchema,
|
metaFileModrinthSourceSchema,
|
||||||
z.object({ type: z.literal("raw") })
|
z.object({ type: z.literal("raw") })
|
||||||
])
|
]).and(z.object({
|
||||||
|
ignoreUpdates: z.boolean().default(false)
|
||||||
|
})).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ModFile = z.output<typeof modFileSchema>
|
export type MetaFileContent = z.output<typeof metaFileContentSchema>
|
||||||
|
|
||||||
export async function readModFile(packPath: Path, modId: string): Promise<ModFile | null> {
|
export const listSourceFiles = (sourceDirectoryPath: AbsolutePath) => fastGlob(sides.map(side => `${side}/**/*`), {
|
||||||
return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema)
|
cwd: sourceDirectoryPath.toString(),
|
||||||
}
|
followSymbolicLinks: false,
|
||||||
|
onlyFiles: true
|
||||||
export async function writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
}).then(paths => paths.map(path => RelativePath.create(path)))
|
||||||
await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeModFile(packPath: Path, modId: string): Promise<void> {
|
|
||||||
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readModIds(packPath: Path) {
|
|
||||||
const modsPath = packPath.resolve("mods")
|
|
||||||
await fs.mkdirp(modsPath.toString())
|
|
||||||
const files = await fs.readdir(modsPath.toString(), { withFileTypes: true })
|
|
||||||
|
|
||||||
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOverrideDirents(overridesDirectoryPath: Path): Promise<Dirent[]> {
|
|
||||||
if (!await fs.pathExists(overridesDirectoryPath.toString())) return []
|
|
||||||
|
|
||||||
return await fs.readdir(overridesDirectoryPath.toString(), { withFileTypes: true })
|
|
||||||
}
|
|
||||||
|
|
199
src/main.ts
199
src/main.ts
|
@ -1,197 +1,34 @@
|
||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import kleur from "kleur"
|
|
||||||
import { usePack } from "./pack.js"
|
|
||||||
import loudRejection from "loud-rejection"
|
|
||||||
import { modrinthCommand } from "./commands/modrinth.js"
|
|
||||||
import { packwizCommand } from "./commands/packwiz.js"
|
|
||||||
import dedent from "dedent"
|
|
||||||
import { default as wrapAnsi } from "wrap-ansi"
|
|
||||||
import { CURRENT_HORIZR_FILE_FORMAT_VERSION, HorizrFile, removeModFile } from "./files.js"
|
|
||||||
import { output } from "./output.js"
|
|
||||||
import figures from "figures"
|
|
||||||
import { releaseChannelOrder } from "./shared.js"
|
|
||||||
import fs from "fs-extra"
|
import fs from "fs-extra"
|
||||||
import { Path } from "./path.js"
|
import { AbsolutePath } from "./utils/path.js"
|
||||||
import enquirer from "enquirer"
|
import { output } from "./utils/output.js"
|
||||||
import { clearCache } from "./utils.js"
|
import kleur from "kleur"
|
||||||
import { fetchFabricMinecraftVersions, fetchFabricVersions } from "./fabricApi.js"
|
import loudRejection from "loud-rejection"
|
||||||
|
import { clearGotCache } from "./utils/http.js"
|
||||||
|
import { initCommand } from "./commands/init.js"
|
||||||
|
import { infoCommand } from "./commands/info.js"
|
||||||
|
import { updateCommand } from "./commands/update.js"
|
||||||
|
import { packwizCommand } from "./commands/packwiz/index.js"
|
||||||
|
import { modrinthCommand } from "./commands/modrinth/index.js"
|
||||||
|
|
||||||
const program = new Command("horizr")
|
const program = new Command("horizr")
|
||||||
.version(
|
.version(
|
||||||
(await fs.readJson(Path.create(import.meta.url.slice(5)).getParent().resolve("../package.json").toString())).version,
|
(await fs.readJson(AbsolutePath.create(import.meta.url.slice(5)).parent().resolve("../package.json").toString())).version,
|
||||||
"-v, --version"
|
"-v, --version"
|
||||||
)
|
)
|
||||||
.option("--clear-cache", "Clear the HTTP cache before doing the operation.")
|
.option("--clear-cache", "Clear the HTTP cache before doing the operation.")
|
||||||
.on("option:clear-cache", () => {
|
.on("option:clear-cache", () => {
|
||||||
clearCache()
|
clearGotCache()
|
||||||
output.println(kleur.green("Cache was cleared.\n"))
|
output.println(kleur.green("Cache was cleared.\n"))
|
||||||
})
|
})
|
||||||
|
.addCommand(modrinthCommand)
|
||||||
program.command("init <path>")
|
.addCommand(packwizCommand)
|
||||||
.description("Initialize a new pack in the directory.")
|
.addCommand(infoCommand)
|
||||||
.action(async path => {
|
.addCommand(initCommand)
|
||||||
const directoryPath = Path.create(path)
|
.addCommand(updateCommand)
|
||||||
const horizrFilePath = directoryPath.resolve("horizr.json")
|
|
||||||
|
|
||||||
if (await fs.pathExists(horizrFilePath.toString())) output.failAndExit(`${kleur.yellow("horizr.json")} already exists in the directory.`)
|
|
||||||
|
|
||||||
await fs.mkdirp(directoryPath.toString())
|
|
||||||
const minecraftVersions = await output.withLoading(fetchFabricMinecraftVersions(), "Fetching Minecraft versions")
|
|
||||||
|
|
||||||
const answers: any = await enquirer.prompt([
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
type: "input",
|
|
||||||
message: "Name",
|
|
||||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "authors",
|
|
||||||
type: "input",
|
|
||||||
message: "Authors (comma-separated)",
|
|
||||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
type: "text",
|
|
||||||
message: "Description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "license",
|
|
||||||
type: "text",
|
|
||||||
message: "License (SPDX-ID)",
|
|
||||||
validate: answer => answer.length === 0 ? "An answer is required." : true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "minecraftVersion",
|
|
||||||
type: "autocomplete",
|
|
||||||
message: "Minecraft version",
|
|
||||||
choices: minecraftVersions.map(version => ({
|
|
||||||
name: version,
|
|
||||||
value: version
|
|
||||||
})),
|
|
||||||
// @ts-expect-error
|
|
||||||
limit: 10,
|
|
||||||
validate: answer => minecraftVersions.includes(answer) ? true : "Please select a version from the list."
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const fabricVersion = (await output.withLoading(fetchFabricVersions(answers.minecraftVersion), "Fetching latest Fabric version"))[0]
|
|
||||||
|
|
||||||
const file: HorizrFile = {
|
|
||||||
formatVersion: CURRENT_HORIZR_FILE_FORMAT_VERSION,
|
|
||||||
meta: {
|
|
||||||
name: answers.name,
|
|
||||||
version: "1.0.0",
|
|
||||||
description: answers.description === "" ? undefined : answers.description,
|
|
||||||
authors: (answers.authors as string).split(", ").map(a => a.trim()),
|
|
||||||
license: answers.license
|
|
||||||
},
|
|
||||||
versions: {
|
|
||||||
minecraft: answers.minecraftVersion,
|
|
||||||
fabric: fabricVersion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeJson(horizrFilePath.toString(), file, { spaces: 2 })
|
|
||||||
await fs.writeFile(directoryPath.resolve(".gitignore").toString(), "/generated/")
|
|
||||||
|
|
||||||
const relativePath = Path.create(process.cwd()).relative(directoryPath).toString()
|
|
||||||
if (relativePath === "") output.println(kleur.green(`Successfully initialized pack.`))
|
|
||||||
else output.println(kleur.green(`Successfully initialized pack in ${kleur.yellow(relativePath)}.`))
|
|
||||||
})
|
|
||||||
|
|
||||||
program.command("info", { isDefault: true })
|
|
||||||
.description("Print information about the pack.")
|
|
||||||
.action(async () => {
|
|
||||||
const pack = await usePack()
|
|
||||||
const disabledModsCount = pack.mods.filter(mod => !mod.modFile.enabled).length
|
|
||||||
const { description } = pack.horizrFile.meta
|
|
||||||
|
|
||||||
output.println(dedent`
|
|
||||||
${kleur.underline(pack.horizrFile.meta.name)} ${kleur.dim(`(${pack.horizrFile.meta.version})`)}
|
|
||||||
${description === undefined ? "" : wrapAnsi(description, process.stdout.columns) + "\n"}\
|
|
||||||
|
|
||||||
Authors: ${kleur.yellow(pack.horizrFile.meta.authors.join(", "))}
|
|
||||||
License: ${kleur.yellow(pack.horizrFile.meta.license.toUpperCase())}
|
|
||||||
Mods: ${kleur.yellow(pack.mods.length.toString())}${disabledModsCount === 0 ? "" : ` (${disabledModsCount} disabled)`}
|
|
||||||
|
|
||||||
Minecraft version: ${kleur.yellow(pack.horizrFile.versions.minecraft)}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
program.command("remove <code>")
|
|
||||||
.description("Remove the mod from the pack.")
|
|
||||||
.action(async code => {
|
|
||||||
const pack = await usePack()
|
|
||||||
const mod = pack.findModByCodeOrFail(code)
|
|
||||||
|
|
||||||
await removeModFile(pack.paths.root, mod.id)
|
|
||||||
|
|
||||||
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
program.command("update [code]")
|
|
||||||
.description("Check for updates of all mods or update a specific mod")
|
|
||||||
.option("-y, --yes", "Skip confirmations")
|
|
||||||
.option("-b, --allow-beta", "Allow beta versions")
|
|
||||||
.option("-a, --allow-alpha", "Allow alpha and beta versions")
|
|
||||||
.action(async (code, options) => {
|
|
||||||
const pack = await usePack()
|
|
||||||
const allowedReleaseChannels = releaseChannelOrder.slice(releaseChannelOrder.indexOf(options.allowAlpha ? "alpha" : options.allowBeta ? "beta" : "release"))
|
|
||||||
|
|
||||||
if (code === undefined) {
|
|
||||||
const updates = await pack.checkForUpdates(allowedReleaseChannels)
|
|
||||||
|
|
||||||
if (updates.length === 0) output.println(kleur.green("Everything up-to-date."))
|
|
||||||
else {
|
|
||||||
output.println(dedent`
|
|
||||||
${kleur.underline("Available updates")}
|
|
||||||
|
|
||||||
${updates.map(update => `- ${kleur.gray(update.mod.id)} ${update.mod.modFile.name}: ${kleur.red(update.activeVersion)} ${figures.arrowRight} ${kleur.green(update.availableVersion)}`).join("\n")}
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const mod = pack.findModByCodeOrFail(code)
|
|
||||||
const update = await output.withLoading(mod.checkForUpdate(allowedReleaseChannels), "Checking for an update")
|
|
||||||
|
|
||||||
if (update === null) {
|
|
||||||
output.println(kleur.green("No update available."))
|
|
||||||
} else {
|
|
||||||
if (update.changelog === null) {
|
|
||||||
output.println(`No changelog available for ${kleur.bold(update.availableVersion)}.`)
|
|
||||||
} else {
|
|
||||||
output.println(`${kleur.underline("Changelog")} for ${kleur.bold().yellow(update.availableVersion)}\n`)
|
|
||||||
output.printlnWrapping(update.changelog)
|
|
||||||
}
|
|
||||||
|
|
||||||
output.println("")
|
|
||||||
|
|
||||||
const confirmed = options.yes || (await enquirer.prompt({
|
|
||||||
type: "confirm",
|
|
||||||
name: "confirmed",
|
|
||||||
message: "Apply the update?"
|
|
||||||
}) as any).confirmed
|
|
||||||
|
|
||||||
if (confirmed) {
|
|
||||||
await output.withLoading(update.apply(), "Updating")
|
|
||||||
output.println(kleur.green(`Successfully updated ${kleur.yellow(update.mod.modFile.name)} to ${kleur.yellow(update.availableVersion)}.`))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
loudRejection(stack => {
|
loudRejection(stack => {
|
||||||
output.failAndExit(stack)
|
output.failAndExit(stack)
|
||||||
})
|
})
|
||||||
|
|
||||||
await program
|
await program.parseAsync(process.argv)
|
||||||
.addCommand(packwizCommand)
|
|
||||||
.addCommand(modrinthCommand)
|
|
||||||
.addHelpText("after", "\n" + dedent`
|
|
||||||
${kleur.blue("code")} can be one of the following:
|
|
||||||
- The name of a file in the ${kleur.yellow("mods")} directory, optionally without the ${kleur.yellow(".json")} extension
|
|
||||||
- The ID of a Modrinth Project, prefixed with ${kleur.yellow("mr:")}
|
|
||||||
- The ID of a Modrinth Version, prefixed with ${kleur.yellow("mrv:")}
|
|
||||||
`)
|
|
||||||
.parseAsync(process.argv)
|
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
import { HTTPError, Response } from "got"
|
import { HTTPError, Response } from "got"
|
||||||
|
import { output } from "../utils/output.js"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { delay, got } from "../utils.js"
|
import { got } from "../utils/http.js"
|
||||||
import { output } from "../output.js"
|
import { delay } from "../utils/promises.js"
|
||||||
import { dependencyToRelatedVersionType } from "./utils.js"
|
import { dependencyToRelatedVersionType } from "./index.js"
|
||||||
import { ReleaseChannel } from "../shared.js"
|
import { ReleaseChannel } from "../pack.js"
|
||||||
|
import { orEmptyString } from "../utils/strings.js"
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.modrinth.com"
|
||||||
|
|
||||||
async function getModrinthApiOptional(url: string): Promise<any | null> {
|
async function getModrinthApiOptional(url: string): Promise<any | null> {
|
||||||
let response: Response
|
let response: Response
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
response = await got(url, {
|
response = await got(url, {
|
||||||
prefixUrl: "https://api.modrinth.com",
|
prefixUrl: BASE_URL,
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
retry: {
|
retry: {
|
||||||
limit: 3,
|
limit: 3,
|
||||||
|
@ -56,7 +60,7 @@ async function getModrinthApiOptional(url: string): Promise<any | null> {
|
||||||
|
|
||||||
async function getModrinthApi(url: string): Promise<any> {
|
async function getModrinthApi(url: string): Promise<any> {
|
||||||
const response = await getModrinthApiOptional(url)
|
const response = await getModrinthApiOptional(url)
|
||||||
if (response === null) return output.failAndExit("Request failed with status code 404.")
|
if (response === null) return output.failAndExit(`Request failed with status code 404: ${kleur.yellow(BASE_URL + "/" + url)}`)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,8 +192,8 @@ export const modrinthApi = {
|
||||||
updateDate: new Date(response.updated)
|
updateDate: new Date(response.updated)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async listVersions(idOrSlug: string, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
async listVersions(idOrSlug: string, minecraftVersion?: string): Promise<ModrinthVersion[]> {
|
||||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]&game_versions=["${minecraftVersion}"]`)
|
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["fabric"]${orEmptyString(minecraftVersion, v => `&game_versions=["${v}"]`)}`)
|
||||||
|
|
||||||
return response.map(transformApiModVersion)
|
return response.map(transformApiModVersion)
|
||||||
},
|
},
|
||||||
|
|
110
src/modrinth/index.ts
Normal file
110
src/modrinth/index.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import { IterableElement } from "type-fest"
|
||||||
|
import { modrinthApi, ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
||||||
|
import { sortBy } from "lodash-es"
|
||||||
|
import { MetaFile, Pack, releaseChannelOrder } from "../pack.js"
|
||||||
|
import { MetaFileContentVersion } from "../files.js"
|
||||||
|
import { output } from "../utils/output.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
|
||||||
|
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
||||||
|
required: "hard_dependency",
|
||||||
|
optional: "soft_dependency",
|
||||||
|
embedded: "embedded_dependency",
|
||||||
|
incompatible: "incompatible"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
||||||
|
|
||||||
|
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
||||||
|
modrinthVersion.supportedMinecraftVersions.includes(pack.manifest.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric")
|
||||||
|
|
||||||
|
export function getMetaFileContentVersionForModrinth(modrinthVersion: ModrinthVersion): MetaFileContentVersion {
|
||||||
|
const modrinthVersionFile = findCorrectModVersionFile(modrinthVersion.files)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: modrinthVersion.versionString,
|
||||||
|
fileName: modrinthVersionFile.fileName,
|
||||||
|
hashes: {
|
||||||
|
sha1: modrinthVersionFile.hashes.sha1,
|
||||||
|
sha512: modrinthVersionFile.hashes.sha512
|
||||||
|
},
|
||||||
|
downloadUrl: modrinthVersionFile.url,
|
||||||
|
size: modrinthVersionFile.sizeInBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
||||||
|
const primary = files.find(file => file.isPrimary)
|
||||||
|
|
||||||
|
if (primary !== undefined) return primary
|
||||||
|
|
||||||
|
// shortest file name
|
||||||
|
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSideOfModrinthMod = (modrinthMod: ModrinthMod) =>
|
||||||
|
modrinthMod.serverSide !== "unsupported" && modrinthMod.clientSide !== "unsupported"
|
||||||
|
? "universal"
|
||||||
|
: modrinthMod.clientSide !== "unsupported" ? "client" : "server"
|
||||||
|
|
||||||
|
export async function resolveModrinthCode(code: string): Promise<{ modrinthMod: ModrinthMod; modrinthVersion: ModrinthVersion | null }> {
|
||||||
|
const resolveMod = async (slugOrId: string) => {
|
||||||
|
const modrinthMod = await modrinthApi.getMod(slugOrId)
|
||||||
|
if (modrinthMod === null) return output.failAndExit(`Unknown mod: ${kleur.yellow(slugOrId)}`)
|
||||||
|
return {
|
||||||
|
modrinthMod,
|
||||||
|
modrinthVersion: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveVersionByName = async (modrinthMod: ModrinthMod, name: string) => {
|
||||||
|
const modrinthVersions = await modrinthApi.listVersions(modrinthMod.id)
|
||||||
|
|
||||||
|
const modrinthVersion = modrinthVersions.find(v => v.versionString === name)
|
||||||
|
if (modrinthVersion === undefined) return output.failAndExit(`Unknown version: ${kleur.yellow(name)}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
|
||||||
|
modrinthVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveVersion = async (id: string) => {
|
||||||
|
const modrinthVersion = await modrinthApi.getVersion(id)
|
||||||
|
if (modrinthVersion === null) return output.failAndExit(`Unknown version: ${kleur.yellow(id)}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
modrinthMod: (await modrinthApi.getMod(modrinthVersion.projectId))!,
|
||||||
|
modrinthVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = code.split("@")
|
||||||
|
if (parts.length === 2 && parts[0] === "") return resolveVersion(code.slice(1))
|
||||||
|
if (parts.length <= 2 && !code.startsWith("https://")) {
|
||||||
|
const value = await resolveMod(parts[0])
|
||||||
|
|
||||||
|
if (parts.length === 2) return resolveVersionByName(value.modrinthMod, parts[1])
|
||||||
|
else return value
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(code)
|
||||||
|
const pathSegments = url.pathname.slice(1).split("/")
|
||||||
|
if (!(code.startsWith("https://modrinth.com/mod/") && (pathSegments.length === 2 || pathSegments.length === 4)))
|
||||||
|
output.failAndExit("Only Modrinth mod and version URLs are supported.")
|
||||||
|
|
||||||
|
const value = await resolveMod(pathSegments[1])
|
||||||
|
|
||||||
|
if (pathSegments.length === 4) return resolveVersionByName(value.modrinthMod, pathSegments[3])
|
||||||
|
else return value
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// TypeError means code is not a URL
|
||||||
|
if (!(e instanceof TypeError)) throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.failAndExit(`Invalid ${kleur.yellow("<code>")}: ${kleur.yellow(code)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findMetaFileForModrinthMod = (metaFiles: MetaFile[], modrinthMod: ModrinthMod) =>
|
||||||
|
metaFiles.find(metaFile => metaFile.content.source?.type === "modrinth" && metaFile.content.source.modId === modrinthMod.id) ?? null
|
56
src/modrinth/updating.ts
Normal file
56
src/modrinth/updating.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { MetaFile, ReleaseChannel, Update } from "../pack.js"
|
||||||
|
import { MetaFileModrinthSource } from "../files.js"
|
||||||
|
import { modrinthApi, ModrinthVersion } from "./api.js"
|
||||||
|
import semver from "semver"
|
||||||
|
import { getMetaFileContentVersionForModrinth } from "./index.js"
|
||||||
|
import { sortBy } from "lodash-es"
|
||||||
|
|
||||||
|
async function fetchNewerModrinthVersions(
|
||||||
|
activeVersion: string,
|
||||||
|
source: MetaFileModrinthSource,
|
||||||
|
allowedReleaseChannels: ReleaseChannel[],
|
||||||
|
minecraftVersion: string
|
||||||
|
): Promise<ModrinthVersion[]> {
|
||||||
|
const activeSemver = semver.parse(activeVersion)
|
||||||
|
const availableVersions = await modrinthApi.listVersions(source.modId, minecraftVersion)
|
||||||
|
const allowedVersions = availableVersions.filter(version => allowedReleaseChannels.includes(version.releaseChannel))
|
||||||
|
|
||||||
|
if (activeSemver === null) {
|
||||||
|
const activePublicationDate = allowedVersions.find(v => v.id === source.versionId)?.publicationDate
|
||||||
|
if (activePublicationDate === undefined) return allowedVersions
|
||||||
|
|
||||||
|
return allowedVersions.filter(v => v.publicationDate.toISOString() > activePublicationDate.toISOString())
|
||||||
|
} else {
|
||||||
|
return allowedVersions.filter(version => {
|
||||||
|
const thisSemver = semver.parse(version.versionString)
|
||||||
|
|
||||||
|
// If mods switch to a non-SemVer version scheme, all new versions are considered older.
|
||||||
|
// This may be a problem.
|
||||||
|
if (thisSemver === null) return false
|
||||||
|
|
||||||
|
return thisSemver.compare(activeSemver) === 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchModrinthModUpdates(
|
||||||
|
metaFile: MetaFile,
|
||||||
|
source: MetaFileModrinthSource,
|
||||||
|
allowedReleaseChannels: ReleaseChannel[],
|
||||||
|
minecraftVersion: string
|
||||||
|
): Promise<Update[]> {
|
||||||
|
const sorted = sortBy(await fetchNewerModrinthVersions(metaFile.content.version.name, source, allowedReleaseChannels, minecraftVersion), v => v.publicationDate.toISOString())
|
||||||
|
.reverse()
|
||||||
|
|
||||||
|
return sorted.map(modrinthVersion => ({
|
||||||
|
of: metaFile,
|
||||||
|
versionString: modrinthVersion.versionString,
|
||||||
|
changelog: modrinthVersion.changelog,
|
||||||
|
async apply() {
|
||||||
|
metaFile.content.version = getMetaFileContentVersionForModrinth(modrinthVersion)
|
||||||
|
source.versionId = modrinthVersion.id
|
||||||
|
|
||||||
|
await metaFile.saveContent()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
|
@ -1,91 +0,0 @@
|
||||||
import { IterableElement } from "type-fest"
|
|
||||||
import { sortBy } from "lodash-es"
|
|
||||||
import { Mod, Pack, usePack } from "../pack.js"
|
|
||||||
import { ModFile, ModFileData, ModFileModrinthSource } from "../files.js"
|
|
||||||
import { pathExists } from "fs-extra"
|
|
||||||
import { nanoid } from "nanoid/non-secure"
|
|
||||||
import { output } from "../output.js"
|
|
||||||
import kleur from "kleur"
|
|
||||||
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
|
||||||
import { releaseChannelOrder, Side } from "../shared.js"
|
|
||||||
|
|
||||||
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
|
||||||
required: "hard_dependency",
|
|
||||||
optional: "soft_dependency",
|
|
||||||
embedded: "embedded_dependency",
|
|
||||||
incompatible: "incompatible"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
|
||||||
|
|
||||||
export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<(Mod & { modFile: ModFile & { source: ModFileModrinthSource } }) | null> {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
return (
|
|
||||||
pack.mods.find(
|
|
||||||
mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === modrinthMod.id
|
|
||||||
) as (Mod & { modFile: Mod & { source: ModFileModrinthSource } }) | undefined
|
|
||||||
) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
|
||||||
modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes("fabric")
|
|
||||||
|
|
||||||
export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData {
|
|
||||||
const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files)
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: modrinthModVersion.versionString,
|
|
||||||
hashes: {
|
|
||||||
sha1: modrinthVersionFile.hashes.sha1,
|
|
||||||
sha512: modrinthVersionFile.hashes.sha512
|
|
||||||
},
|
|
||||||
downloadUrl: modrinthVersionFile.url,
|
|
||||||
name: modrinthVersionFile.fileName,
|
|
||||||
size: modrinthVersionFile.sizeInBytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, side?: Side) {
|
|
||||||
const pack = await usePack()
|
|
||||||
let id = modrinthMod.slug
|
|
||||||
|
|
||||||
if (await pathExists(pack.paths.mods.resolve(`${id}.json`).toString())) {
|
|
||||||
const oldId = id
|
|
||||||
id = `${id}-${nanoid(5)}`
|
|
||||||
|
|
||||||
output.warn(
|
|
||||||
`There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` +
|
|
||||||
`The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (side === undefined) {
|
|
||||||
const isClientSupported = modrinthMod.clientSide !== "unsupported"
|
|
||||||
const isServerSupported = modrinthMod.serverSide !== "unsupported"
|
|
||||||
|
|
||||||
side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server"
|
|
||||||
}
|
|
||||||
|
|
||||||
await pack.addMod(id, {
|
|
||||||
name: modrinthMod.title,
|
|
||||||
enabled: true,
|
|
||||||
ignoreUpdates: false,
|
|
||||||
side,
|
|
||||||
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
|
|
||||||
source: {
|
|
||||||
type: "modrinth",
|
|
||||||
modId: modrinthMod.id,
|
|
||||||
versionId: modrinthVersion.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
|
||||||
const primary = files.find(file => file.isPrimary)
|
|
||||||
|
|
||||||
if (primary !== undefined) return primary
|
|
||||||
|
|
||||||
// shortest file name
|
|
||||||
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
|
||||||
}
|
|
287
src/pack.ts
287
src/pack.ts
|
@ -1,173 +1,162 @@
|
||||||
import { findPackDirectoryPath, getOverrideDirents, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
|
import { AbsolutePath, envPaths, RelativePath } from "./utils/path.js"
|
||||||
import { output } from "./output.js"
|
import {
|
||||||
import pLimit from "p-limit"
|
findPackDirectoryPath,
|
||||||
|
PackManifest,
|
||||||
|
horizrFileSchema,
|
||||||
|
MetaFileContent,
|
||||||
|
PACK_MANIFEST_FILE_NAME,
|
||||||
|
metaFileContentSchema,
|
||||||
|
writeJsonFile,
|
||||||
|
listSourceFiles,
|
||||||
|
readJsonFile, META_FILE_EXTENSION
|
||||||
|
} from "./files.js"
|
||||||
|
import { z, ZodRawShape } from "zod"
|
||||||
|
import { fetchModrinthModUpdates } from "./modrinth/updating.js"
|
||||||
|
import { createCpuCoreLimiter } from "./utils/promises.js"
|
||||||
|
import { output } from "./utils/output.js"
|
||||||
|
import pathModule from "path"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { modrinthApi } from "./modrinth/api.js"
|
import { orEmptyString } from "./utils/strings.js"
|
||||||
import semver from "semver"
|
import fs from "fs-extra"
|
||||||
import { Path } from "./path.js"
|
|
||||||
import { ReleaseChannel, Side, sides } from "./shared.js"
|
export type ReleaseChannel = "alpha" | "beta" | "release"
|
||||||
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
|
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
||||||
|
|
||||||
|
export type Side = "client" | "server" | "universal"
|
||||||
|
export const sides: [Side, ...Side[]] = ["client", "server", "universal"]
|
||||||
|
|
||||||
|
export interface Pack {
|
||||||
|
manifest: PackManifest
|
||||||
|
|
||||||
|
paths: {
|
||||||
|
root: AbsolutePath
|
||||||
|
source: AbsolutePath
|
||||||
|
exports: AbsolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
metaFiles: MetaFile[]
|
||||||
|
staticSourceFiles: StaticSourceFile[]
|
||||||
|
getMetaFile(path: RelativePath): MetaFile | null
|
||||||
|
getMetaFileFromInput(input: string): MetaFile
|
||||||
|
getEffectiveMetaFile(path: RelativePath, side: Side): MetaFile | null
|
||||||
|
registerCreatedSourceFile(path: RelativePath): Promise<void>
|
||||||
|
|
||||||
|
readSourceJsonFile<S extends z.ZodObject<ZodRawShape>>(path: RelativePath, schema: S): Promise<z.output<S> | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceFile {
|
||||||
|
isStatic: boolean
|
||||||
|
isMod: boolean
|
||||||
|
side: Side
|
||||||
|
relativePath: RelativePath
|
||||||
|
absolutePath: AbsolutePath
|
||||||
|
effectivePath: RelativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaFile extends SourceFile {
|
||||||
|
isStatic: false
|
||||||
|
content: MetaFileContent
|
||||||
|
fetchUpdates: null | ((allowedReleaseChannels: ReleaseChannel[]) => Promise<Update[]>)
|
||||||
|
saveContent(): Promise<void>
|
||||||
|
|
||||||
|
getDisplayString(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticSourceFile extends SourceFile {
|
||||||
|
isStatic: true
|
||||||
|
}
|
||||||
|
|
||||||
export interface Update {
|
export interface Update {
|
||||||
mod: Mod
|
of: MetaFile
|
||||||
activeVersion: string
|
versionString: string
|
||||||
availableVersion: string
|
|
||||||
changelog: string | null
|
changelog: string | null
|
||||||
apply(): Promise<void>
|
apply(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pack {
|
|
||||||
paths: {
|
|
||||||
root: Path,
|
|
||||||
mods: Path,
|
|
||||||
generated: Path,
|
|
||||||
overrides: Record<Side, Path>
|
|
||||||
},
|
|
||||||
horizrFile: HorizrFile
|
|
||||||
mods: Mod[]
|
|
||||||
|
|
||||||
addMod(id: string, file: ModFile): Promise<void>
|
|
||||||
findModByCode(code: string): Mod | null
|
|
||||||
findModByCodeOrFail(code: string): Mod
|
|
||||||
validateOverridesDirectories(): Promise<void>
|
|
||||||
|
|
||||||
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Mod {
|
|
||||||
id: string
|
|
||||||
|
|
||||||
modFile: ModFile
|
|
||||||
saveModFile(): Promise<void>
|
|
||||||
|
|
||||||
checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
let pack: Pack
|
let pack: Pack
|
||||||
|
|
||||||
export async function usePack(): Promise<Pack> {
|
export async function usePack(): Promise<Pack> {
|
||||||
if (pack === undefined) {
|
if (pack === undefined) {
|
||||||
const rootDirectoryPath = await findPackDirectoryPath()
|
const rootDirectoryPath = await findPackDirectoryPath()
|
||||||
const overridesDirectoryPath = rootDirectoryPath.resolve("overrides")
|
const sourceDirectoryPath = rootDirectoryPath.resolve("src")
|
||||||
|
const readSourceJsonFile: Pack["readSourceJsonFile"] = async (path, schema) => readJsonFile(sourceDirectoryPath, path, schema)
|
||||||
|
|
||||||
|
const manifest = (await readJsonFile(rootDirectoryPath, RelativePath.create(PACK_MANIFEST_FILE_NAME), horizrFileSchema))!
|
||||||
|
|
||||||
|
const metaFiles: MetaFile[] = []
|
||||||
|
const staticSourceFiles: StaticSourceFile[] = []
|
||||||
|
|
||||||
|
const registerSourceFile: Pack["registerCreatedSourceFile"] = async relativePath => {
|
||||||
|
const absolutePath = sourceDirectoryPath.resolve(relativePath)
|
||||||
|
if (!await fs.pathExists(absolutePath.toString())) throw new Error("File does not exist: " + absolutePath)
|
||||||
|
|
||||||
|
const pathSegments = relativePath.toString().split("/")
|
||||||
|
|
||||||
|
const sourceFile: SourceFile = {
|
||||||
|
isStatic: false,
|
||||||
|
isMod: pathSegments[1] === "mods",
|
||||||
|
side: pathSegments[0] as Side,
|
||||||
|
relativePath: relativePath,
|
||||||
|
absolutePath: sourceDirectoryPath.resolve(relativePath),
|
||||||
|
effectivePath: RelativePath._createDirect(pathSegments.slice(1).join("/")),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath.toString().endsWith("." + META_FILE_EXTENSION)) {
|
||||||
|
const content = (await readSourceJsonFile(relativePath, metaFileContentSchema))!
|
||||||
|
const { source } = content
|
||||||
|
|
||||||
|
const metaFile: MetaFile = {
|
||||||
|
...sourceFile,
|
||||||
|
isStatic: false,
|
||||||
|
content,
|
||||||
|
fetchUpdates: source?.type === "modrinth"
|
||||||
|
? allowedReleaseChannels => fetchModrinthModUpdates(metaFile, source, allowedReleaseChannels, manifest.versions.minecraft)
|
||||||
|
: null,
|
||||||
|
async saveContent() {
|
||||||
|
await writeJsonFile(sourceDirectoryPath.resolve(relativePath), metaFileContentSchema, this.content)
|
||||||
|
},
|
||||||
|
getDisplayString: () => `${kleur.yellow(metaFile.relativePath.toString())}${orEmptyString(metaFile.content.displayName, v => " " + kleur.blue(v))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
metaFiles.push(metaFile)
|
||||||
|
} else {
|
||||||
|
staticSourceFiles.push({
|
||||||
|
...sourceFile,
|
||||||
|
isStatic: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFilePaths = await listSourceFiles(sourceDirectoryPath)
|
||||||
|
const limit = createCpuCoreLimiter()
|
||||||
|
await Promise.all(sourceFilePaths.map(path => limit(() => registerSourceFile(path))))
|
||||||
|
|
||||||
pack = {
|
pack = {
|
||||||
paths: {
|
paths: {
|
||||||
root: rootDirectoryPath,
|
root: rootDirectoryPath,
|
||||||
generated: rootDirectoryPath.resolve("generated"),
|
source: sourceDirectoryPath,
|
||||||
mods: rootDirectoryPath.resolve("mods"),
|
exports: rootDirectoryPath.resolve("exports")
|
||||||
overrides: {
|
|
||||||
client: overridesDirectoryPath.resolve("client"),
|
|
||||||
server: overridesDirectoryPath.resolve("server"),
|
|
||||||
"client-server": overridesDirectoryPath.resolve("client-server")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
horizrFile: await readHorizrFile(rootDirectoryPath),
|
manifest,
|
||||||
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
|
metaFiles,
|
||||||
const mod: Mod = {
|
staticSourceFiles,
|
||||||
id,
|
readSourceJsonFile,
|
||||||
modFile: (await readModFile(rootDirectoryPath, id))!,
|
registerCreatedSourceFile: registerSourceFile,
|
||||||
async saveModFile() {
|
getMetaFile(relativePath: RelativePath) {
|
||||||
await writeModFile(rootDirectoryPath, id, this.modFile)
|
return metaFiles.find(metaFile => metaFile.relativePath.is(relativePath)) ?? null
|
||||||
},
|
},
|
||||||
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
|
getEffectiveMetaFile(effectivePath: RelativePath, side: Side) {
|
||||||
if (mod.modFile.ignoreUpdates) return null
|
return metaFiles.find(metaFile => metaFile.side === side && metaFile.effectivePath.is(effectivePath)) ?? null
|
||||||
|
|
||||||
if (mod.modFile.source.type === "modrinth") {
|
|
||||||
const activeVersionString = mod.modFile.file.version
|
|
||||||
const activeSemver = semver.parse(activeVersionString)
|
|
||||||
if (activeSemver === null) output.warn(
|
|
||||||
`${kleur.yellow(mod.modFile.name)} has no valid semantic version: ${kleur.yellow(mod.modFile.file.version)}. ` +
|
|
||||||
`The publication date will instead be used.`
|
|
||||||
)
|
|
||||||
|
|
||||||
const versions = await modrinthApi.listVersions(mod.modFile.source.modId, pack.horizrFile.versions.minecraft)
|
|
||||||
const allowedVersions = versions.filter(version => allowedReleaseChannels.includes(version.releaseChannel))
|
|
||||||
|
|
||||||
const newerVersions = activeSemver === null ? allowedVersions : allowedVersions.filter(version => {
|
|
||||||
const thisSemver = semver.parse(version.versionString)
|
|
||||||
if (thisSemver === null) return false
|
|
||||||
|
|
||||||
return thisSemver.compare(activeSemver) === 1
|
|
||||||
})
|
|
||||||
|
|
||||||
if (newerVersions.length === 0) return null
|
|
||||||
|
|
||||||
const sortedNewerVersions = sortModrinthVersionsByPreference(newerVersions)
|
|
||||||
const newestVersion = sortedNewerVersions[0]
|
|
||||||
|
|
||||||
if (activeSemver === null ? activeVersionString === newestVersion.versionString : semver.eq(activeSemver, newestVersion.versionString)) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
mod,
|
|
||||||
activeVersion: activeVersionString,
|
|
||||||
availableVersion: newestVersion.versionString,
|
|
||||||
changelog: newestVersion.changelog,
|
|
||||||
async apply() {
|
|
||||||
const modrinthMod = (await modrinthApi.getMod(newestVersion.projectId))!
|
|
||||||
|
|
||||||
mod.modFile.file = getModFileDataForModrinthVersion(modrinthMod, newestVersion)
|
|
||||||
;(mod.modFile.source as ModFileModrinthSource).versionId = newestVersion.id
|
|
||||||
|
|
||||||
await mod.saveModFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
output.warn(`${kleur.yellow(mod.modFile.name)} has no source information attached.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mod
|
|
||||||
})),
|
|
||||||
async addMod(id: string, file: ModFile) {
|
|
||||||
await writeModFile(rootDirectoryPath, id, file)
|
|
||||||
},
|
},
|
||||||
findModByCode(code: string): Mod | null {
|
getMetaFileFromInput(input: string): MetaFile {
|
||||||
if (code.startsWith("mrv:")) {
|
const path = envPaths.cwd.resolveAny(input)
|
||||||
return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.versionId === code.slice(4)) ?? null
|
if (!path.isDescendantOf(sourceDirectoryPath)) output.failAndExit(`${kleur.yellow(pathModule.normalize(input))} is outside the source directory.`)
|
||||||
} else if (code.startsWith("mr:")) {
|
|
||||||
return this.mods.find(mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === code.slice(3)) ?? null
|
|
||||||
} else if (code.endsWith(".json")) {
|
|
||||||
return this.mods.find(mod => mod.id === code.slice(0, -5)) ?? null
|
|
||||||
} else {
|
|
||||||
return this.mods.find(mod => mod.id === code) ?? null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
findModByCodeOrFail(code: string): Mod {
|
|
||||||
const mod = this.findModByCode(code)
|
|
||||||
if (mod === null) return output.failAndExit("The mod could not be found.")
|
|
||||||
return mod
|
|
||||||
},
|
|
||||||
async validateOverridesDirectories() {
|
|
||||||
const dirents = await getOverrideDirents(overridesDirectoryPath)
|
|
||||||
|
|
||||||
const notDirectories = dirents.filter(dirent => !dirent.isDirectory())
|
const relativePath = sourceDirectoryPath.relativeTo(path.toString().endsWith("." + META_FILE_EXTENSION) ? path : (path.toString() + "." + META_FILE_EXTENSION))
|
||||||
if (notDirectories.length !== 0)
|
|
||||||
output.failAndExit(
|
|
||||||
`The ${kleur.yellow("overrides")} directory contains files that are not directories:\n${notDirectories.slice(0, 5).map(e => `- ${e.name}`).join("\n")}` +
|
|
||||||
(notDirectories.length > 5 ? `\n${kleur.gray(`and ${notDirectories.length - 5} more`)}` : "") +
|
|
||||||
`\n\nAll files must reside in one of these sub-directories: ${sides.map(kleur.yellow).join(", ")}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (dirents.some(dirent => !(sides as string[]).includes(dirent.name)))
|
const metaFile = this.getMetaFile(relativePath)
|
||||||
output.failAndExit(`The ${kleur.yellow("overrides")} directory may only contain the following sub-directories:\n${sides.map(side => `- ${side}`).join("\n")}`)
|
if (metaFile === null) return output.failAndExit(`${kleur.yellow(relativePath.toString())} does not exist.`)
|
||||||
},
|
|
||||||
async checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]> {
|
|
||||||
const limit = pLimit(5)
|
|
||||||
|
|
||||||
const loader = output.startLoading(`Checking for updates (0/${this.mods.length})`)
|
return metaFile
|
||||||
let finishedCount = 0
|
|
||||||
const updates: Array<Update | null> = await Promise.all(this.mods.map(mod => limit(async () => {
|
|
||||||
const update = await mod.checkForUpdate(allowedReleaseChannels)
|
|
||||||
finishedCount++
|
|
||||||
loader.setText(`Checking for updates (${finishedCount}/${this.mods.length})`)
|
|
||||||
return update
|
|
||||||
})))
|
|
||||||
|
|
||||||
loader.stop()
|
|
||||||
return updates.filter(info => info !== null) as Update[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
104
src/packwiz/exporting.ts
Normal file
104
src/packwiz/exporting.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import { AbsolutePath, RelativePath } from "../utils/path.js"
|
||||||
|
import dedent from "dedent"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { MetaFile, StaticSourceFile, usePack } from "../pack.js"
|
||||||
|
import pathModule from "path"
|
||||||
|
import { computeSha512HexHash, computeSha512HexHashForFile } from "../utils/misc.js"
|
||||||
|
import { orEmptyString } from "../utils/strings.js"
|
||||||
|
import { META_FILE_EXTENSION } from "../files.js"
|
||||||
|
|
||||||
|
export const PACKWIZ_EXPORT_DIRECTORY_NAME = "packwiz"
|
||||||
|
|
||||||
|
export interface IndexedFile {
|
||||||
|
path: RelativePath
|
||||||
|
sha512HashHex: string
|
||||||
|
isMeta: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAndIndexStaticSourceFile(
|
||||||
|
indexedFiles: IndexedFile[],
|
||||||
|
outputDirectoryPath: AbsolutePath,
|
||||||
|
staticSourceFile: StaticSourceFile
|
||||||
|
) {
|
||||||
|
const outputPath = outputDirectoryPath.resolve(staticSourceFile.effectivePath)
|
||||||
|
|
||||||
|
await fs.mkdirp(outputPath.parent().toString())
|
||||||
|
await fs.copy(staticSourceFile.absolutePath.toString(), outputPath.toString())
|
||||||
|
|
||||||
|
indexedFiles.push({
|
||||||
|
path: staticSourceFile.effectivePath,
|
||||||
|
isMeta: false,
|
||||||
|
sha512HashHex: await computeSha512HexHashForFile(outputPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAndIndexMetaFile(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath, metaFile: MetaFile) {
|
||||||
|
const updateSection = metaFile.content.source?.type === "modrinth"
|
||||||
|
? dedent`
|
||||||
|
\n\n[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = ${JSON.stringify(metaFile.content.source.modId)}
|
||||||
|
version = ${JSON.stringify(metaFile.content.source.versionId)}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const content = dedent`
|
||||||
|
name = ${JSON.stringify(metaFile.content.displayName ?? pathModule.basename(metaFile.relativePath.toString()))}
|
||||||
|
filename = ${JSON.stringify(metaFile.content.version.fileName)}
|
||||||
|
side = "${metaFile.side.replace("universal", "both")}"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = ${JSON.stringify(metaFile.content.version.hashes.sha512)}
|
||||||
|
url = ${JSON.stringify(metaFile.content.version.downloadUrl)}${updateSection}
|
||||||
|
`
|
||||||
|
|
||||||
|
const effectiveOutputPath = metaFile.effectivePath
|
||||||
|
.parent()
|
||||||
|
.joinedWith(metaFile.effectivePath.getBasename().slice(0, -1 * META_FILE_EXTENSION.length) + "toml")
|
||||||
|
|
||||||
|
const outputPath = outputDirectoryPath.resolve(effectiveOutputPath)
|
||||||
|
|
||||||
|
await fs.mkdirp(outputPath.parent().toString())
|
||||||
|
await fs.writeFile(outputPath.toString(), content)
|
||||||
|
|
||||||
|
indexedFiles.push({
|
||||||
|
path: metaFile.effectivePath,
|
||||||
|
isMeta: true,
|
||||||
|
sha512HashHex: await computeSha512HexHash(content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeIndexAndPackManifest(indexedFiles: IndexedFile[], outputDirectoryPath: AbsolutePath) {
|
||||||
|
const pack = await usePack()
|
||||||
|
|
||||||
|
const index = dedent`
|
||||||
|
hash-format = "sha512"
|
||||||
|
|
||||||
|
${indexedFiles.map(file => dedent`
|
||||||
|
[[files]]
|
||||||
|
file = ${JSON.stringify(file.path.toString())}
|
||||||
|
hash = "${file.sha512HashHex}"
|
||||||
|
metafile = ${file.isMeta}
|
||||||
|
`).join("\n\n")}
|
||||||
|
`
|
||||||
|
|
||||||
|
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
||||||
|
const indexHash = await computeSha512HexHash(index)
|
||||||
|
|
||||||
|
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
|
||||||
|
name = ${JSON.stringify(pack.manifest.meta.name)}
|
||||||
|
author = ${JSON.stringify(pack.manifest.meta.authors.join(", "))}\
|
||||||
|
${orEmptyString(pack.manifest.meta.description, d => `\ndescription = ${JSON.stringify(d)}`)}
|
||||||
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
minecraft = ${JSON.stringify(pack.manifest.versions.minecraft)}
|
||||||
|
fabric = ${JSON.stringify(pack.manifest.versions.fabric)}
|
||||||
|
|
||||||
|
[index]
|
||||||
|
file = "index.toml"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "${indexHash}"
|
||||||
|
`)
|
||||||
|
}
|
67
src/path.ts
67
src/path.ts
|
@ -1,67 +0,0 @@
|
||||||
import pathModule from "path"
|
|
||||||
import envPaths from "env-paths"
|
|
||||||
|
|
||||||
export class Path {
|
|
||||||
constructor(private readonly value: string) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an absolute path by resolving the last segment against the other segments, this path and the current working directory.
|
|
||||||
*/
|
|
||||||
resolve(...segments: (string | Path)[]) {
|
|
||||||
return new Path(pathModule.resolve(this.value, ...segments.map(s => s.toString())))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new path with this path and the segments joined together.
|
|
||||||
*/
|
|
||||||
join(...segments: (string | Path)[]) {
|
|
||||||
return new Path(pathModule.join(this.value, ...segments.map(s => s.toString())))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the relative path from this path to the other path.
|
|
||||||
*/
|
|
||||||
relative(other: Path | string) {
|
|
||||||
return new Path(pathModule.relative(this.value, typeof other === "string" ? other : other.toString()))
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent() {
|
|
||||||
return new Path(pathModule.dirname(this.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
isAbsolute() {
|
|
||||||
return pathModule.isAbsolute(this.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not tested
|
|
||||||
// isDescendantOf(other: Path) {
|
|
||||||
// if (!(this.isAbsolute() && other.isAbsolute())) throw new Error("Both paths must be absolute")
|
|
||||||
// return pathModule.relative(this.value, other.value).split("/").includes("..")
|
|
||||||
// }
|
|
||||||
|
|
||||||
toString() {
|
|
||||||
return this.value
|
|
||||||
}
|
|
||||||
|
|
||||||
static create(...segments: string[]) {
|
|
||||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
|
||||||
|
|
||||||
return new Path(pathModule.join(...segments))
|
|
||||||
}
|
|
||||||
|
|
||||||
static createAbsolute(...segments: string[]) {
|
|
||||||
if (segments.length === 0) throw new Error("At least one segment is required")
|
|
||||||
|
|
||||||
return new Path(pathModule.resolve(...segments))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPaths = envPaths("horizr", { suffix: "" })
|
|
||||||
export const paths = {
|
|
||||||
cache: new Path(rawPaths.cache),
|
|
||||||
config: new Path(rawPaths.config),
|
|
||||||
data: new Path(rawPaths.data),
|
|
||||||
log: new Path(rawPaths.log),
|
|
||||||
temp: new Path(rawPaths.temp)
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
export type ReleaseChannel = "alpha" | "beta" | "release"
|
|
||||||
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
|
||||||
|
|
||||||
export type Side = "client" | "server" | "client-server"
|
|
||||||
export const sides: [Side, ...Side[]] = ["client", "server", "client-server"]
|
|
128
src/utils.ts
128
src/utils.ts
|
@ -1,128 +0,0 @@
|
||||||
import { InvalidArgumentError } from "commander"
|
|
||||||
import hash, { HashaInput } from "hasha"
|
|
||||||
import { Path, paths } from "./path.js"
|
|
||||||
import { ZipFile } from "yazl"
|
|
||||||
import { walk } from "@root/walk"
|
|
||||||
import fs from "fs-extra"
|
|
||||||
import { pEvent } from "p-event"
|
|
||||||
import serveHandler from "serve-handler"
|
|
||||||
import * as http from "http"
|
|
||||||
import addressWithCallback from "address"
|
|
||||||
import { promisify } from "util"
|
|
||||||
import { KeyvFile } from "keyv-file"
|
|
||||||
import originalGot from "got"
|
|
||||||
import { dirname } from "path"
|
|
||||||
import { without } from "lodash-es"
|
|
||||||
|
|
||||||
const keyvCache = new KeyvFile({
|
|
||||||
filename: paths.cache.resolve("http.json").toString(),
|
|
||||||
writeDelay: 50,
|
|
||||||
expiredCheckDelay: 24 * 3600 * 1000,
|
|
||||||
encode: JSON.stringify,
|
|
||||||
decode: JSON.parse
|
|
||||||
})
|
|
||||||
|
|
||||||
export const clearCache = () => keyvCache.clear()
|
|
||||||
|
|
||||||
export const got = originalGot.extend({
|
|
||||||
cache: keyvCache,
|
|
||||||
responseType: "json",
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "moritzruth/horizr/1.0.0 (not yet public)"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const address = promisify(addressWithCallback)
|
|
||||||
export const getLANAddress = () => address().then(r => r.ip)
|
|
||||||
|
|
||||||
export function createSingleConcurrencyWithQueue(fn: () => Promise<void>) {
|
|
||||||
let state: "inactive" | "running_fresh" | "running_old" = "inactive"
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
if (state === "inactive") {
|
|
||||||
const loop = () => {
|
|
||||||
state = "running_fresh"
|
|
||||||
|
|
||||||
fn().then(() => {
|
|
||||||
if (state === "running_old") loop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loop()
|
|
||||||
} else {
|
|
||||||
state = "running_old"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) {
|
|
||||||
const server = http.createServer((request, response) => {
|
|
||||||
return serveHandler(request, response, {
|
|
||||||
directoryListing: false,
|
|
||||||
public: path.toString(),
|
|
||||||
cleanUrls: false,
|
|
||||||
headers: [
|
|
||||||
{
|
|
||||||
source: "**/*.toml",
|
|
||||||
headers: [{
|
|
||||||
key: "Content-Type",
|
|
||||||
value: "application/toml"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => {
|
|
||||||
onListen()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function zipDirectory(directoryPath: Path, outputFilePath: Path) {
|
|
||||||
const zipFile = new ZipFile()
|
|
||||||
zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString()))
|
|
||||||
|
|
||||||
let emptyDirectories: string[] = []
|
|
||||||
await walk(directoryPath.toString(), async (error, path, dirent) => {
|
|
||||||
if (error) return
|
|
||||||
if (directoryPath.toString() === path) return true
|
|
||||||
if (dirent.name.startsWith(".")) return false
|
|
||||||
|
|
||||||
if (dirent.isDirectory()) {
|
|
||||||
emptyDirectories.push(path)
|
|
||||||
} else if (dirent.isFile()) {
|
|
||||||
zipFile.addFile(path, directoryPath.relative(path).toString(), { compress: true })
|
|
||||||
} else return
|
|
||||||
|
|
||||||
emptyDirectories = without(emptyDirectories, dirname(path))
|
|
||||||
})
|
|
||||||
|
|
||||||
emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relative(p).toString()))
|
|
||||||
|
|
||||||
zipFile.end()
|
|
||||||
await pEvent(zipFile.outputStream, "close")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
|
|
||||||
export const getSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" })
|
|
||||||
|
|
||||||
export function truncateWithEllipsis(text: string, maxLength: number) {
|
|
||||||
if (text.length <= maxLength) return text
|
|
||||||
|
|
||||||
return text.slice(0, maxLength - 1).trimEnd() + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionParseInteger(value: string): number {
|
|
||||||
const parsed = parseInt(value, 10)
|
|
||||||
if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.")
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionParsePositiveInteger(value: string): number {
|
|
||||||
const parsed = parseInt(value, 10)
|
|
||||||
if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.")
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
|
16
src/utils/collections.ts
Normal file
16
src/utils/collections.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
export function mapNotNull<T, R>(array: T[], fn: (item: T, index: number) => R | null): R[] {
|
||||||
|
const result: R[] = []
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
for (const item of array) {
|
||||||
|
const mapped = fn(item, index)
|
||||||
|
if (mapped !== null) result.push(mapped)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterNulls<T>(array: T[]): Exclude<T, null>[] {
|
||||||
|
return array.filter(i => i !== null) as Exclude<T, null>[]
|
||||||
|
}
|
69
src/utils/http.ts
Normal file
69
src/utils/http.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { KeyvFile } from "keyv-file"
|
||||||
|
import { Path, envPaths } from "./path.js"
|
||||||
|
import originalGot from "got"
|
||||||
|
import http from "http"
|
||||||
|
import serveHandler from "serve-handler"
|
||||||
|
import { getLANAddress } from "./misc.js"
|
||||||
|
import { output } from "./output.js"
|
||||||
|
import dedent from "dedent"
|
||||||
|
import kleur from "kleur"
|
||||||
|
|
||||||
|
const keyvCache = new KeyvFile({
|
||||||
|
filename: envPaths.cache.resolve("http.json").toString(),
|
||||||
|
writeDelay: 50,
|
||||||
|
expiredCheckDelay: 24 * 3600 * 1000,
|
||||||
|
encode: JSON.stringify,
|
||||||
|
decode: JSON.parse
|
||||||
|
})
|
||||||
|
|
||||||
|
export const clearGotCache = () => keyvCache.clear()
|
||||||
|
|
||||||
|
export const got = originalGot.extend({
|
||||||
|
cache: keyvCache,
|
||||||
|
responseType: "json",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "moritzruth/horizr/1.0.0 (not yet public)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function httpServeDirectory(path: Path, port: number, expose: boolean, onListen: () => void) {
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
return serveHandler(request, response, {
|
||||||
|
directoryListing: false,
|
||||||
|
public: path.toString(),
|
||||||
|
cleanUrls: false,
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
source: "**/*.toml",
|
||||||
|
headers: [{
|
||||||
|
key: "Content-Type",
|
||||||
|
value: "application/toml"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, expose ? "0.0.0.0" : "127.0.0.1", () => {
|
||||||
|
onListen()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpServeDirectoryWithMessage(path: Path, port: number, expose: boolean) {
|
||||||
|
const lanAddress = await getLANAddress()
|
||||||
|
const localAddress = `http://localhost:${port}`
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
httpServeDirectory(path, port, expose, () => {
|
||||||
|
if (expose) {
|
||||||
|
output.println(dedent`
|
||||||
|
${kleur.green("Serving at")}
|
||||||
|
Local: ${kleur.yellow(localAddress)}
|
||||||
|
Network: ${kleur.yellow(`http://${lanAddress}:${port}`)}
|
||||||
|
`)
|
||||||
|
} else output.println(`${kleur.green("Serving at")} ${kleur.yellow(localAddress)}`)
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
10
src/utils/misc.ts
Normal file
10
src/utils/misc.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { promisify } from "util"
|
||||||
|
import addressWithCallback from "address"
|
||||||
|
import hash, { HashaInput } from "hasha"
|
||||||
|
import { AbsolutePath } from "./path.js"
|
||||||
|
|
||||||
|
const address = promisify(addressWithCallback)
|
||||||
|
export const getLANAddress = () => address().then(r => r.ip)
|
||||||
|
|
||||||
|
export const computeSha512HexHash = (input: HashaInput) => hash.async(input, { algorithm: "sha512", encoding: "hex" })
|
||||||
|
export const computeSha512HexHashForFile = (path: AbsolutePath) => hash.fromFile(path.toString(), { algorithm: "sha512", encoding: "hex" })
|
29
src/utils/options.ts
Normal file
29
src/utils/options.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { InvalidArgumentError } from "commander"
|
||||||
|
import { Side, sides } from "../pack.js"
|
||||||
|
|
||||||
|
export function integerOption(value: string): number {
|
||||||
|
const parsed = parseInt(value, 10)
|
||||||
|
if (isNaN(parsed)) throw new InvalidArgumentError("Must be an integer.")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function positiveIntegerOption(value: string): number {
|
||||||
|
const parsed = parseInt(value, 10)
|
||||||
|
if (isNaN(parsed) || parsed < 0) throw new InvalidArgumentError("Must be a positive integer.")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gtzIntegerOption(value: string): number {
|
||||||
|
const parsed = parseInt(value, 10)
|
||||||
|
if (isNaN(parsed) || parsed <= 0) throw new InvalidArgumentError("Must be an integer > 0.")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sideOption(value: string): Side {
|
||||||
|
if (!(sides as string[]).includes(value)) throw new InvalidArgumentError(`Must be one of ${sides.join(", ")}`)
|
||||||
|
|
||||||
|
return value as Side
|
||||||
|
}
|
131
src/utils/path.ts
Normal file
131
src/utils/path.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import pathModule from "path"
|
||||||
|
import getEnvPaths from "env-paths"
|
||||||
|
|
||||||
|
interface AbstractPath {
|
||||||
|
isDescendantOf(other: Path): boolean
|
||||||
|
is(other: Path | string): boolean
|
||||||
|
|
||||||
|
getBasename(): string
|
||||||
|
|
||||||
|
toAbsolute(): AbsolutePath
|
||||||
|
toString(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Path = AbsolutePath | RelativePath
|
||||||
|
|
||||||
|
export class RelativePath implements AbstractPath {
|
||||||
|
private constructor(private readonly pathString: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
isDescendantOf(other: Path) {
|
||||||
|
return this.pathString !== "" && !this.pathString.split("/").includes("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveInCwd(...segments: (string | RelativePath)[]): AbsolutePath {
|
||||||
|
return AbsolutePath._createDirect(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedWith(...segments: (string | Path)[]): RelativePath {
|
||||||
|
return RelativePath._createDirect(pathModule.join(this.pathString, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
parent(): RelativePath {
|
||||||
|
return RelativePath._createDirect(pathModule.dirname(this.pathString))
|
||||||
|
}
|
||||||
|
|
||||||
|
is(other: Path | string): boolean {
|
||||||
|
return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
getBasename(): string {
|
||||||
|
return pathModule.basename(this.pathString)
|
||||||
|
}
|
||||||
|
|
||||||
|
toAbsolute(): AbsolutePath {
|
||||||
|
return envPaths.cwd.resolve(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.pathString
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(pathString: string) {
|
||||||
|
if (pathModule.isAbsolute(pathString)) throw new Error("pathString is not relative")
|
||||||
|
return new RelativePath(pathModule.normalize(pathString))
|
||||||
|
}
|
||||||
|
|
||||||
|
static _createDirect(pathString: string) {
|
||||||
|
return new RelativePath(pathString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbsolutePath implements AbstractPath {
|
||||||
|
private constructor(private readonly pathString: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
isDescendantOf(other: Path) {
|
||||||
|
if (other instanceof AbsolutePath) {
|
||||||
|
return other.relativeTo(this).isDescendantOf(this)
|
||||||
|
} else return other.isDescendantOf(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(...segments: (string | RelativePath)[]): AbsolutePath {
|
||||||
|
return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAny(...segments: (string | Path)[]): AbsolutePath {
|
||||||
|
return new AbsolutePath(pathModule.resolve(this.pathString, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedWith(...segments: (string | RelativePath)[]): AbsolutePath {
|
||||||
|
return new AbsolutePath(pathModule.join(this.pathString, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
parent(): AbsolutePath {
|
||||||
|
return new AbsolutePath(pathModule.dirname(this.pathString))
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeTo(other: Path | string): RelativePath {
|
||||||
|
if (other instanceof RelativePath) return other
|
||||||
|
else return RelativePath._createDirect(pathModule.relative(this.pathString, typeof other === "string" ? other : other.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
is(other: Path | string): boolean {
|
||||||
|
return this.pathString === (typeof other === "string" ? pathModule.normalize(other) : other.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
getBasename(): string {
|
||||||
|
return pathModule.basename(this.pathString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Unnecessary.
|
||||||
|
*/
|
||||||
|
toAbsolute(): AbsolutePath {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.pathString
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(pathString: string) {
|
||||||
|
if (!pathModule.isAbsolute(pathString)) throw new Error("pathString is not absolute")
|
||||||
|
return new AbsolutePath(pathModule.normalize(pathString))
|
||||||
|
}
|
||||||
|
|
||||||
|
static _createDirect(pathString: string) {
|
||||||
|
return new AbsolutePath(pathString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPaths = getEnvPaths("horizr", { suffix: "" })
|
||||||
|
|
||||||
|
export const envPaths = {
|
||||||
|
cache: AbsolutePath.create(rawPaths.cache),
|
||||||
|
config: AbsolutePath.create(rawPaths.config),
|
||||||
|
data: AbsolutePath.create(rawPaths.data),
|
||||||
|
log: AbsolutePath.create(rawPaths.log),
|
||||||
|
temp: AbsolutePath.create(rawPaths.temp),
|
||||||
|
cwd: AbsolutePath.create(process.cwd())
|
||||||
|
}
|
26
src/utils/promises.ts
Normal file
26
src/utils/promises.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import pLimit from "p-limit"
|
||||||
|
import os from "os"
|
||||||
|
|
||||||
|
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
export const createCpuCoreLimiter = () => pLimit(os.cpus().length)
|
||||||
|
|
||||||
|
export function createSingleConcurrencyWithQueue(fn: () => Promise<void>) {
|
||||||
|
let state: "inactive" | "running_fresh" | "running_old" = "inactive"
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
if (state === "inactive") {
|
||||||
|
const loop = () => {
|
||||||
|
state = "running_fresh"
|
||||||
|
|
||||||
|
fn().then(() => {
|
||||||
|
if (state === "running_old") loop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loop()
|
||||||
|
} else {
|
||||||
|
state = "running_old"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
src/utils/strings.ts
Normal file
9
src/utils/strings.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export const orEmptyString =
|
||||||
|
<T>(value: T, fn: (v: Exclude<T, undefined | null>) => string): string =>
|
||||||
|
value === undefined || value === null ? "" : fn(value as Exclude<T, undefined | null>)
|
||||||
|
|
||||||
|
export function truncateWithEllipsis(text: string, maxLength: number) {
|
||||||
|
if (text.length <= maxLength) return text
|
||||||
|
|
||||||
|
return text.slice(0, maxLength - 1).trimEnd() + "…"
|
||||||
|
}
|
32
src/utils/zip.ts
Normal file
32
src/utils/zip.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { AbsolutePath } from "./path.js"
|
||||||
|
import { ZipFile } from "yazl"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { walk } from "@root/walk"
|
||||||
|
import { without } from "lodash-es"
|
||||||
|
import { pEvent } from "p-event"
|
||||||
|
import { dirname } from "path"
|
||||||
|
|
||||||
|
export async function zipDirectory(directoryPath: AbsolutePath, outputFilePath: AbsolutePath) {
|
||||||
|
const zipFile = new ZipFile()
|
||||||
|
zipFile.outputStream.pipe(fs.createWriteStream(outputFilePath.toString()))
|
||||||
|
|
||||||
|
let emptyDirectories: string[] = []
|
||||||
|
await walk(directoryPath.toString(), async (error, path, dirent) => {
|
||||||
|
if (error) return
|
||||||
|
if (directoryPath.toString() === path) return true
|
||||||
|
if (dirent.name.startsWith(".")) return false
|
||||||
|
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
emptyDirectories.push(path)
|
||||||
|
} else if (dirent.isFile()) {
|
||||||
|
zipFile.addFile(path, directoryPath.relativeTo(path).toString(), { compress: true })
|
||||||
|
} else return
|
||||||
|
|
||||||
|
emptyDirectories = without(emptyDirectories, dirname(path))
|
||||||
|
})
|
||||||
|
|
||||||
|
emptyDirectories.forEach(p => zipFile.addEmptyDirectory(directoryPath.relativeTo(p).toString()))
|
||||||
|
|
||||||
|
zipFile.end()
|
||||||
|
await pEvent(zipFile.outputStream, "close")
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import { take } from "lodash-es"
|
import { take } from "lodash-es"
|
||||||
import { usePack } from "../pack.js"
|
import { usePack } from "../../pack.js"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../utils.js"
|
import { optionParsePositiveInteger, truncateWithEllipsis, zipDirectory } from "../../utils.js"
|
||||||
import { default as wrapAnsi } from "wrap-ansi"
|
import { default as wrapAnsi } from "wrap-ansi"
|
||||||
import figures from "figures"
|
import figures from "figures"
|
||||||
import {
|
import {
|
||||||
|
@ -10,13 +10,13 @@ import {
|
||||||
ModrinthMod,
|
ModrinthMod,
|
||||||
ModrinthVersion,
|
ModrinthVersion,
|
||||||
ModrinthVersionRelation,
|
ModrinthVersionRelation,
|
||||||
} from "../modrinth/api.js"
|
} from "../../modrinth/api.js"
|
||||||
import dedent from "dedent"
|
import dedent from "dedent"
|
||||||
import ago from "s-ago"
|
import ago from "s-ago"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { output } from "../output.js"
|
import { output } from "../../../src/utils/output.js"
|
||||||
import fs from "fs-extra"
|
import fs from "fs-extra"
|
||||||
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js"
|
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../../modrinth/utils.js"
|
||||||
import { walk } from "@root/walk"
|
import { walk } from "@root/walk"
|
||||||
|
|
||||||
const modrinthCommand = new Command("modrinth")
|
const modrinthCommand = new Command("modrinth")
|
47
src_old/modrinth/utils.ts
Normal file
47
src_old/modrinth/utils.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { IterableElement } from "type-fest"
|
||||||
|
import { sortBy } from "lodash-es"
|
||||||
|
import { Mod, Pack, usePack } from "../pack.js"
|
||||||
|
import { ModFile, ModFileData, MetaFileModrinthSource } from "../files.js"
|
||||||
|
import { pathExists } from "fs-extra"
|
||||||
|
import { nanoid } from "nanoid/non-secure"
|
||||||
|
import { output } from "../../src/utils/output.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
||||||
|
import { releaseChannelOrder, Side } from "../shared.js"
|
||||||
|
|
||||||
|
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, side?: Side) {
|
||||||
|
const pack = await usePack()
|
||||||
|
let id = modrinthMod.slug
|
||||||
|
|
||||||
|
if (await pathExists(pack.paths.mods.resolve(`${id}.json`).toString())) {
|
||||||
|
const oldId = id
|
||||||
|
id = `${id}-${nanoid(5)}`
|
||||||
|
|
||||||
|
output.warn(
|
||||||
|
`There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` +
|
||||||
|
`The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === undefined) {
|
||||||
|
const isClientSupported = modrinthMod.clientSide !== "unsupported"
|
||||||
|
const isServerSupported = modrinthMod.serverSide !== "unsupported"
|
||||||
|
|
||||||
|
side = isClientSupported && isServerSupported ? "client-server" : isClientSupported ? "client" : "server"
|
||||||
|
}
|
||||||
|
|
||||||
|
await pack.addMod(id, {
|
||||||
|
name: modrinthMod.title,
|
||||||
|
enabled: true,
|
||||||
|
ignoreUpdates: false,
|
||||||
|
side,
|
||||||
|
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
|
||||||
|
source: {
|
||||||
|
type: "modrinth",
|
||||||
|
modId: modrinthMod.id,
|
||||||
|
versionId: modrinthVersion.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
31
test-pack/exports/packwiz/index.toml
Normal file
31
test-pack/exports/packwiz/index.toml
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
hash-format = "sha512"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/sodium.hm.json"
|
||||||
|
hash = "955b4e74bc9b1988cdbcfa659fb977997ebf99dbd701e4b2aa6175cacd4a763b39399ea5e16db5536cb59708e0f9ce84746c1611519c0689d640da6752b496e3"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "resourcepacks/better-leaves.hm.json"
|
||||||
|
hash = "81fc1a6887ad61e4ed5662d7173efd743280bd2ad640fdebd495b30ee915bdbe9b2882b55ca7dabac155ba1e4520ad3957f76cc1ac98dce0ef7087d3c07beed9"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/charm.hm.json"
|
||||||
|
hash = "c388539aa188902f671e27de1bf8306ded8ed1ba7dc5cb3a9fd3b6f13895a789255d36c025b1ef2172c6123d99e28201660c83657d7d36a318c4387249c69931"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/fabric-api.hm.json"
|
||||||
|
hash = "c9ca7daa8ed64738ddd1fa4d334e14e768623322e25f6f8eb38ac616bbef1b3823e55136b22ff7f1e88adec98b44b2ebdb6981ccff0c7e6f1acd4ca91f82c594"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "options.txt"
|
||||||
|
hash = "41ab8c11939b9379f97739cd998a44dae98f92fd3715253d14d979987a22e8ca7c5799396efc3951e16597346c590ae0bbbbf31dba9d7004c4459a3930322f20"
|
||||||
|
metafile = false
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "config/charm.ini"
|
||||||
|
hash = "21ed919c05480f55d202dc97c33ae55d897acec162ac75d5f62d914ce5a18fbaac83dc631d4690896e3b6135da1305d0f3f73e1df3b54fbc777b186367ba421d"
|
||||||
|
metafile = false
|
13
test-pack/exports/packwiz/mods/charm.toml
Normal file
13
test-pack/exports/packwiz/mods/charm.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name = "Charm"
|
||||||
|
filename = "charm-fabric-1.18.2-4.2.0.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "3c8cd08ab1e37dcbf0f5a956cd20d84c98e58ab49fdc13faafb9c2af4dbf7fba7c8328cb5365997fe4414cfc5cb554ed13b3056a22df1c6bd335594f380facb6"
|
||||||
|
url = "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "pOQTcQmj"
|
||||||
|
version = "BT9G1Jjs"
|
13
test-pack/exports/packwiz/mods/fabric-api.toml
Normal file
13
test-pack/exports/packwiz/mods/fabric-api.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name = "Fabric API"
|
||||||
|
filename = "fabric-api-0.58.0+1.18.2.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed"
|
||||||
|
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "P7dR8mSH"
|
||||||
|
version = "4XRtXhtL"
|
13
test-pack/exports/packwiz/mods/sodium.toml
Normal file
13
test-pack/exports/packwiz/mods/sodium.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name = "Sodium"
|
||||||
|
filename = "sodium-fabric-mc1.18.2-0.4.1+build.15.jar"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "86eb4db8fdb9f0bb06274c4f150b55273b5b770ffc89e0ba68011152a231b79ebe0b1adda0dd194f92cdcb386f7a60863d9fee5d15c1c3547ffa22a19083a1ee"
|
||||||
|
url = "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "AANobbMI"
|
||||||
|
version = "74Y5Z8fo"
|
13
test-pack/exports/packwiz/pack.toml
Normal file
13
test-pack/exports/packwiz/pack.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name = "Test"
|
||||||
|
author = "John Doe"
|
||||||
|
description = "A test pack for testing the horizr CLI. It is not intended for playing."
|
||||||
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
minecraft = "1.18.2"
|
||||||
|
fabric = "0.14.7"
|
||||||
|
|
||||||
|
[index]
|
||||||
|
file = "index.toml"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "8173efc3a86743de3b35e88cb01fbe10f12b13ce4c99fd730abaeb81ee88d736078cc2dfcf45a12b36ded174c73ac59304f6074b2ec916babc5c941adc170299"
|
|
@ -0,0 +1,8 @@
|
||||||
|
name = "better-leaves.hm.json"
|
||||||
|
filename = "Better-Leaves.zip"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817"
|
||||||
|
url = "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip"
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Fabric API",
|
|
||||||
"enabled": true,
|
|
||||||
"ignoreUpdates": false,
|
|
||||||
"side": "client-server",
|
|
||||||
"file": {
|
|
||||||
"version": "0.58.0+1.18.2",
|
|
||||||
"name": "fabric-api-0.58.0+1.18.2.jar",
|
|
||||||
"size": 1445029,
|
|
||||||
"downloadUrl": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.18.2/fabric-api-0.58.0%2B1.18.2.jar",
|
|
||||||
"hashes": {
|
|
||||||
"sha1": "b9ab9ab267f8cdff525f9a8edb26435d3e2455f6",
|
|
||||||
"sha512": "92317b8d48b20d1b370ab67e4954d1db4861b8fb561935edc0c0fc8a525fefbd3c159f3cfbf83ec3455e3179561fab554645138c6d79f5f597abea77dc1a03ed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"source": {
|
|
||||||
"type": "modrinth",
|
|
||||||
"modId": "P7dR8mSH",
|
|
||||||
"versionId": "4XRtXhtL"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "Sodium",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ignoreUpdates": false,
|
"version": {
|
||||||
"side": "client",
|
"name": "mc1.18.2-0.4.1",
|
||||||
"file": {
|
|
||||||
"version": "mc1.18.2-0.4.1",
|
|
||||||
"name": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
|
||||||
"size": 1318645,
|
"size": 1318645,
|
||||||
|
"fileName": "sodium-fabric-mc1.18.2-0.4.1+build.15.jar",
|
||||||
"downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar",
|
"downloadUrl": "https://cdn.modrinth.com/data/AANobbMI/versions/mc1.18.2-0.4.1/sodium-fabric-mc1.18.2-0.4.1%2Bbuild.15.jar",
|
||||||
"hashes": {
|
"hashes": {
|
||||||
"sha1": "f839863a6be7014b8d80058ea1f361521148d049",
|
"sha1": "f839863a6be7014b8d80058ea1f361521148d049",
|
||||||
|
@ -16,6 +13,7 @@
|
||||||
"source": {
|
"source": {
|
||||||
"type": "modrinth",
|
"type": "modrinth",
|
||||||
"modId": "AANobbMI",
|
"modId": "AANobbMI",
|
||||||
"versionId": "74Y5Z8fo"
|
"versionId": "74Y5Z8fo",
|
||||||
|
"ignoreUpdates": false
|
||||||
}
|
}
|
||||||
}
|
}
|
1
test-pack/src/client/options.txt
Normal file
1
test-pack/src/client/options.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
option=1
|
15
test-pack/src/client/resourcepacks/better-leaves.hm.json
Normal file
15
test-pack/src/client/resourcepacks/better-leaves.hm.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "Better Leaves",
|
||||||
|
"enabled": true,
|
||||||
|
"side": "client-server",
|
||||||
|
"version": {
|
||||||
|
"name": "7.0.0",
|
||||||
|
"fileName": "Better-Leaves.zip",
|
||||||
|
"size": 447282,
|
||||||
|
"downloadUrl": "https://mediafiles.forgecdn.net/files/3814/725/Better-Leaves-7.0-1.13%2B.zip",
|
||||||
|
"hashes": {
|
||||||
|
"sha1": "b768ea104fbf268ab69fcdc4004c7f36df15c545",
|
||||||
|
"sha512": "7a1a5f925251db5cd19e3ce44f5acdf3941221b20e40c253b8c451adaa406c4d4d66dd424244802e34029f4a14ed2594f4e1c550c7c48dc365c8d9ebfc0cd817"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
test-pack/src/server/mods/lithium.hm.json
Normal file
19
test-pack/src/server/mods/lithium.hm.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"version": {
|
||||||
|
"name": "mc1.18.2-0.7.10",
|
||||||
|
"size": 466196,
|
||||||
|
"fileName": "lithium-fabric-mc1.18.2-0.7.10.jar",
|
||||||
|
"downloadUrl": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.18.2-0.7.10/lithium-fabric-mc1.18.2-0.7.10.jar",
|
||||||
|
"hashes": {
|
||||||
|
"sha1": "d5c19c3d4edb4228652adcc8abb94f9bd80a634c",
|
||||||
|
"sha512": "05f0e51191c9051224c791d63ad4b7915e6f3c442e5d38225e7b05ea4261ee459edb3d8ce99411e1a5a854547549845f21cc8ee2f0079281fec999c1d319fb07"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "modrinth",
|
||||||
|
"modId": "gvQqBUqZ",
|
||||||
|
"versionId": "pHl1Vi6k",
|
||||||
|
"ignoreUpdates": false
|
||||||
|
}
|
||||||
|
}
|
1
test-pack/src/universal/config/charm.ini
Normal file
1
test-pack/src/universal/config/charm.ini
Normal file
|
@ -0,0 +1 @@
|
||||||
|
test=42
|
|
@ -1,12 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "Charm",
|
"displayName": "Charm",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ignoreUpdates": false,
|
"version": {
|
||||||
"side": "client-server",
|
"name": "4.2.0+1.18.2",
|
||||||
"file": {
|
|
||||||
"version": "4.2.0+1.18.2",
|
|
||||||
"name": "charm-fabric-1.18.2-4.2.0.jar",
|
|
||||||
"size": 3413876,
|
"size": 3413876,
|
||||||
|
"fileName": "charm-fabric-1.18.2-4.2.0.jar",
|
||||||
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
|
"downloadUrl": "https://cdn.modrinth.com/data/pOQTcQmj/versions/4.2.0+1.18.2/charm-fabric-1.18.2-4.2.0.jar",
|
||||||
"hashes": {
|
"hashes": {
|
||||||
"sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82",
|
"sha1": "ebb87cd7fa7935bc30e5ad0b379bb4ede8723a82",
|
||||||
|
@ -16,6 +14,7 @@
|
||||||
"source": {
|
"source": {
|
||||||
"type": "modrinth",
|
"type": "modrinth",
|
||||||
"modId": "pOQTcQmj",
|
"modId": "pOQTcQmj",
|
||||||
"versionId": "BT9G1Jjs"
|
"versionId": "BT9G1Jjs",
|
||||||
|
"ignoreUpdates": false
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Add table
Reference in a new issue