Skip to content

Commit 25bde16

Browse files
authored
Merge pull request #22 from arjuncgore/main
CLI for Modcheck
2 parents 5a7aa75 + 2ac1810 commit 25bde16

3 files changed

Lines changed: 302 additions & 2 deletions

File tree

gradlew

100644100755
File mode changed.

src/main/kotlin/com/pistacium/modcheck/Meta.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import kotlinx.serialization.*
55
@Serializable
66
data class Meta(val schemaVersion: Int, val mods: List<Mod>) {
77
@Serializable
8-
data class Mod @OptIn(ExperimentalSerializationApi::class) constructor(
8+
@OptIn(ExperimentalSerializationApi::class)
9+
data class Mod(
910
val modid: String,
1011
val name: String,
1112
val description: String,
@@ -20,7 +21,8 @@ data class Meta(val schemaVersion: Int, val mods: List<Mod>) {
2021
}
2122

2223
@Serializable
23-
data class ModVersion @OptIn(ExperimentalSerializationApi::class) constructor(
24+
@OptIn(ExperimentalSerializationApi::class)
25+
data class ModVersion(
2426
val target_version: List<String>,
2527
val version: String,
2628
val url: String,

src/main/kotlin/com/pistacium/modcheck/ModCheck.kt

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import java.awt.Toolkit
66
import java.awt.datatransfer.StringSelection
77
import java.io.*
88
import java.net.URI
9+
import java.nio.file.*
910
import java.util.concurrent.*
1011
import javax.swing.JOptionPane
12+
import kotlin.io.path.extension
1113
import kotlin.system.exitProcess
1214

1315
object ModCheck {
@@ -27,6 +29,13 @@ object ModCheck {
2729

2830
@JvmStatic
2931
fun main(args: Array<String>) {
32+
// enable cli if arguments are given
33+
if (args.isNotEmpty()) {
34+
handleCliMode(args)
35+
return
36+
}
37+
// else run the gui
38+
3039
FlatDarkLaf.setup()
3140
threadExecutor.submit {
3241
try {
@@ -82,4 +91,293 @@ object ModCheck {
8291
}
8392
}
8493
}
94+
95+
private fun isValidPath(path: String): Boolean {
96+
val basePath = Paths.get(path)
97+
val mcPath = basePath.resolve("minecraft")
98+
val dotMcPath = basePath.resolve(".minecraft")
99+
100+
// Check if the path exists and is a directory
101+
if (!Files.exists(basePath) || !Files.isDirectory(basePath)) {
102+
return false
103+
}
104+
105+
// Check if it's a valid Minecraft instance structure
106+
return when {
107+
Files.isDirectory(mcPath) && Files.isDirectory(mcPath.resolve("mods")) -> true
108+
Files.isDirectory(dotMcPath) && Files.isDirectory(dotMcPath.resolve("mods")) -> true
109+
Files.isDirectory(basePath.resolve("mods")) -> true
110+
basePath.endsWith("mods") && Files.isDirectory(basePath) -> true
111+
else -> false
112+
}
113+
}
114+
115+
private fun handleCliMode(args: Array<String>) {
116+
// Load mod list for CLI
117+
val mods = ModCheckUtils.json.decodeFromString<Meta>(
118+
URI.create("https://raw.githubusercontent.com/tildejustin/mcsr-meta/${if (applicationVersion == "dev") "staging" else "schema-6"}/mods.json").toURL().readText()
119+
).mods
120+
availableMods.addAll(mods)
121+
122+
// Defaults
123+
var category = "rsg"
124+
var os = ModCheckUtils.currentOS()
125+
var accessibility = false
126+
var version = "1.16.1"
127+
var path: String? = null
128+
var function: String? = null
129+
130+
131+
// Parsing args
132+
var i = 0
133+
while (i < args.size) {
134+
when (args[i].lowercase()) {
135+
"help", "-h", "--help" -> {
136+
printHelp()
137+
return
138+
}
139+
"version", "-v" -> {
140+
println("ModCheck version: $applicationVersion")
141+
return
142+
}
143+
"--category" -> {
144+
if (i + 1 < args.size) {
145+
val value = args[i + 1].lowercase()
146+
if (value == "rsg" || value == "ssg") {
147+
category = value
148+
} else {
149+
println("Invalid category: $value")
150+
exitProcess(1)
151+
}
152+
i++
153+
}
154+
}
155+
"--accessibility" -> {
156+
accessibility = true
157+
}
158+
"--version" -> {
159+
if (i + 1 < args.size) {
160+
version = args[i + 1]
161+
i++
162+
}
163+
}
164+
"--path" -> {
165+
if (i + 1 < args.size) {
166+
var pathTemporary = args[i + 1]
167+
var pathIndex = i + 1
168+
169+
// Concatenate arguments until path is valid
170+
while (!isValidPath(pathTemporary) && pathIndex + 1 < args.size) {
171+
// Stop loop if next argument is a flag
172+
if (args[pathIndex + 1].startsWith("-")) {
173+
break
174+
}
175+
pathIndex++
176+
pathTemporary += " " + args[pathIndex]
177+
}
178+
179+
path = pathTemporary
180+
i = pathIndex
181+
}
182+
}
183+
"--instance" -> {
184+
if (i + 1 < args.size) {
185+
var instanceTemporary = args[i + 1]
186+
var instanceIndex = i + 1
187+
val userHome = System.getProperty("user.home")
188+
189+
// Get initial resolved path from instance name
190+
var pathTemporary = when {
191+
os == "windows" -> {
192+
println("Error: --instance is not supported on Windows. Please use --path <directory> instead.")
193+
exitProcess(1)
194+
}
195+
os == "linux" -> "$userHome/.local/share/PrismLauncher/instances/$instanceTemporary"
196+
os == "osx" -> "$userHome/Library/Application Support/PrismLauncher/instances/$instanceTemporary"
197+
else -> {
198+
println("Unknown OS for --instance path resolution: $os")
199+
exitProcess(1)
200+
}
201+
}
202+
203+
// Concatenate arguments until path is valid (same loop as --path)
204+
while (!isValidPath(pathTemporary) && instanceIndex + 1 < args.size) {
205+
// Stop loop if next argument is a flag or command
206+
if (args[instanceIndex + 1].startsWith("-") ||
207+
args[instanceIndex + 1].lowercase() in listOf("download", "update")) {
208+
break
209+
}
210+
instanceIndex++
211+
instanceTemporary += " " + args[instanceIndex]
212+
213+
// Update resolved path with new instance name
214+
pathTemporary = when {
215+
os == "linux" -> "$userHome/.local/share/PrismLauncher/instances/$instanceTemporary"
216+
os == "osx" -> "$userHome/Library/Application Support/PrismLauncher/instances/$instanceTemporary"
217+
else -> pathTemporary // shouldn't reach here
218+
}
219+
}
220+
221+
path = pathTemporary
222+
i = instanceIndex
223+
}
224+
}
225+
"download", "update" -> {
226+
function = args[i].lowercase()
227+
}
228+
else -> {
229+
// edge cases?
230+
}
231+
}
232+
i++
233+
}
234+
235+
if (function == null) {
236+
printHelp()
237+
exitProcess(1)
238+
}
239+
240+
if (path == null) {
241+
println("Error: Either --path <directory> or --instance <name> is required.")
242+
printHelp()
243+
exitProcess(1)
244+
}
245+
246+
// Print out the values
247+
val basePath = Paths.get(path)
248+
val mcPath = basePath.resolve("minecraft")
249+
val dotMcPath = basePath.resolve(".minecraft")
250+
val modsDir = when {
251+
Files.isDirectory(mcPath) && Files.isDirectory(mcPath.resolve("mods")) -> mcPath.resolve("mods")
252+
Files.isDirectory(dotMcPath) && Files.isDirectory(dotMcPath.resolve("mods")) -> dotMcPath.resolve("mods")
253+
Files.isDirectory(basePath.resolve("mods")) -> basePath.resolve("mods")
254+
Files.isDirectory(basePath) && basePath.endsWith("mods") -> basePath
255+
else -> null
256+
}
257+
258+
if (modsDir == null || !Files.exists(modsDir)) {
259+
println("No mods directory found at: $modsDir")
260+
return
261+
}
262+
263+
println("Options:")
264+
println(" Category: ${if (category == "rsg") "Random Seed Glitchless" else "Set Seed Glitchless"}")
265+
println(" OS: ${os.replaceFirstChar { it.uppercase() }}")
266+
println(" Accessibility: $accessibility")
267+
println(" Version: $version")
268+
println(" Mod Folder: $modsDir")
269+
270+
if (function == "download") {
271+
// 1. Select mods
272+
// assumes that there are no conflicting recommended mods, which is a choice in meta design I will try to stick to
273+
val selectedMods = availableMods.filter { mod ->
274+
val modVersion = mod.getModVersion(version)
275+
if (modVersion == null) return@filter false
276+
// prioritize sodium-mac
277+
if (
278+
mod.modid == "sodium" &&
279+
os == "osx" &&
280+
availableMods.find { it.modid == "sodiummac" }
281+
?.versions?.any { version in it.target_version } == true
282+
) return@filter false
283+
if (mod.obsolete || modVersion.obsolete) return@filter false
284+
if (!mod.recommended) return@filter false
285+
for (trait in mod.traits) {
286+
if (trait == "ssg-only" && category != "ssg") return@filter false
287+
if (trait == "rsg-only" && category != "rsg") return@filter false
288+
if (trait == "accessibility" && !accessibility) return@filter false
289+
if (trait == "mac-only" && os != "osx") return@filter false
290+
}
291+
true
292+
}
293+
if (selectedMods.isEmpty()) {
294+
println("Warning: No mods matched the selection criteria. Nothing to download.")
295+
return
296+
}
297+
for (mod in selectedMods) {
298+
println("Selected ${mod.name}")
299+
}
300+
// 2. Download selected mods
301+
var count = 0
302+
for (mod in selectedMods) {
303+
val modVersion = mod.getModVersion(version)!! // null results are thrown out in initial filter
304+
val url = modVersion.url
305+
val filename = url.substringAfterLast("/")
306+
try {
307+
println("Downloading ${mod.name}")
308+
val bytes = URI.create(url).toURL().readBytes()
309+
Files.write(modsDir.resolve(filename), bytes)
310+
} catch (e: Exception) {
311+
println("Failed to download ${mod.name}: ${e.message}")
312+
}
313+
count++
314+
}
315+
println("Downloading mods complete")
316+
} else if (function == "update") {
317+
// 1. Find installed mods
318+
val modFiles = Files.list(modsDir)
319+
val toUpdate = mutableListOf<Triple<Path, Meta.Mod, Meta.ModVersion>>()
320+
for (file in modFiles) {
321+
if (file.extension != "jar") continue
322+
val fmj = try { ModCheckUtils.readFabricModJson(file) } catch (_: Exception) { null }
323+
if (fmj == null) continue
324+
val mod = availableMods.find { it.modid == fmj.id || it.name == fmj.name }
325+
if (mod != null) {
326+
val modVersion = mod.getModVersion(version)
327+
if (modVersion != null && modVersion.version != fmj.version) {
328+
toUpdate.add(Triple(file, mod, modVersion))
329+
}
330+
}
331+
}
332+
if (toUpdate.isEmpty()) {
333+
println("All known mods are up to date.")
334+
return
335+
}
336+
var count = 0
337+
for ((oldFile, mod, modVersion) in toUpdate) {
338+
val url = modVersion.url
339+
val filename = url.substringAfterLast("/")
340+
try {
341+
println("Updating ${mod.name} from ${oldFile.fileName} to $filename")
342+
val bytes = URI.create(url).toURL().readBytes()
343+
Files.write(modsDir.resolve(filename), bytes)
344+
Files.deleteIfExists(oldFile)
345+
} catch (e: Exception) {
346+
println("Failed to update ${mod.name}: ${e.message}")
347+
}
348+
count++
349+
}
350+
println("Updated $count mod(s). Done.")
351+
}
352+
}
353+
354+
private fun printHelp() {
355+
println("""
356+
ModCheck CLI
357+
358+
Usage: java -jar modcheck.jar [options] <download|update>
359+
360+
361+
Options:
362+
--category <rsg|ssg> Specify the category (default: rsg)
363+
--version <version> Specify Minecraft version (default: 1.16.1)
364+
--accessibility Include accessibility mods (default: false)
365+
--instance <name> Specify your instance name (uses default PrismLauncher path)
366+
or
367+
--path <directory> Specify a different path to your instance
368+
369+
help, -h, --help Show this help message
370+
version, -v Show modcheck version information
371+
372+
Commands:
373+
download Download mods
374+
update Update existing mods
375+
376+
Examples:
377+
java -jar modcheck.jar --category random --version 1.16.1 --instance instance1 download
378+
Downloads speedrunning mods for 1.16.1 RSG into instance1
379+
380+
Run without arguments to start the GUI.
381+
""".trimIndent())
382+
}
85383
}

0 commit comments

Comments
 (0)