hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 03:29:51 +0200
committerhachem <im@hachem.wtf>2026-08-29 03:29:51 +0200
commita221b4c5edd3ff4d4a9d74113d9d3b8c7473133d (patch)
treed902da32c84dad3d5fe0152be07d3bea6d4f9ee1
parentf60177934a18daea3b24a37cf109bb2c7bfaaa72 (diff)
add: modrinth client and check command
-rw-r--r--internal/cli/check.go100
-rw-r--r--internal/cli/cli.go3
-rw-r--r--internal/cli/commands.go19
-rw-r--r--internal/cli/styles.go2
-rw-r--r--internal/modrinth/modrinth.go128
-rw-r--r--main.go2
6 files changed, 247 insertions, 7 deletions
diff --git a/internal/cli/check.go b/internal/cli/check.go
new file mode 100644
index 0000000..1df5137
--- /dev/null
+++ b/internal/cli/check.go
@@ -0,0 +1,100 @@
+package cli
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/modrinth"
+)
+
+type compatMod struct {
+ slug string
+ filename string
+}
+
+type checkResult struct {
+ compatible []compatMod
+ incompatible []string
+ failed []string
+}
+
+func runCheck(m *model) tea.Cmd {
+ m.busy = true
+ cfg := m.cfg
+ return tea.Batch(m.spin.Tick, func() tea.Msg {
+ return outputMsg{text: checkReport(cfg)}
+ })
+}
+
+func checkReport(cfg *config.Config) string {
+ client := modrinth.NewClient()
+ var res checkResult
+
+ for _, slug := range cfg.ModrinthMods {
+ versions, err := client.GetVersions(slug)
+ if err != nil {
+ res.failed = append(res.failed, slug)
+ continue
+ }
+
+ match := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric")
+ if match == nil {
+ res.incompatible = append(res.incompatible, slug)
+ continue
+ }
+
+ filename := match.VersionNumber
+ if len(match.Files) > 0 {
+ filename = match.Files[0].Filename
+ }
+ res.compatible = append(res.compatible, compatMod{slug: slug, filename: filename})
+ }
+
+ return renderCheck(cfg, res)
+}
+
+func renderCheck(cfg *config.Config, res checkResult) string {
+ rows := []string{
+ headingStyle.Render(fmt.Sprintf("Compatibility · MC %s · Fabric", cfg.MinecraftVersion)),
+ "",
+ }
+
+ if len(res.compatible) > 0 {
+ rows = append(rows, successStyle.Render("Compatible"))
+ for _, c := range res.compatible {
+ rows = append(rows, " "+successStyle.Render("✓ ")+c.slug+mutedStyle.Render(" → "+c.filename))
+ }
+ rows = append(rows, "")
+ }
+
+ if len(res.incompatible) > 0 {
+ rows = append(rows, errorStyle.Render("No compatible version"))
+ for _, slug := range res.incompatible {
+ rows = append(rows, " "+errorStyle.Render("✗ ")+slug)
+ }
+ rows = append(rows, "")
+ }
+
+ if len(res.failed) > 0 {
+ rows = append(rows, warnStyle.Render("Failed to check"))
+ for _, slug := range res.failed {
+ rows = append(rows, " "+warnStyle.Render("! ")+slug)
+ }
+ rows = append(rows, "")
+ }
+
+ total := len(cfg.ModrinthMods)
+ ok := len(res.compatible)
+ rows = append(rows, mutedStyle.Render(fmt.Sprintf("Summary: %d/%d mods compatible", ok, total)))
+
+ if len(res.incompatible) == 0 && len(res.failed) == 0 {
+ rows = append(rows, successStyle.Render("Safe to generate."))
+ } else {
+ rows = append(rows, warnStyle.Render("Not all mods are compatible — generate may have issues."))
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, rows...)
+}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 4e394e6..6609a7e 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -101,6 +101,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Println(msg.text)
case spinner.TickMsg:
+ if !m.busy {
+ return m, nil
+ }
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 1f1f132..cf7cc90 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -20,6 +20,11 @@ func defaultCommands() []command {
},
},
{
+ name: "check",
+ desc: "Check whether the configured mods are compatible",
+ run: runCheck,
+ },
+ {
name: "help",
desc: "Show available commands",
run: func(m *model) tea.Cmd {
@@ -30,15 +35,19 @@ func defaultCommands() []command {
}
func welcomeBlock(cfg *config.Config, version string) string {
+ header := lipgloss.JoinHorizontal(
+ lipgloss.Bottom,
+ titleStyle.Render("BENSON"),
+ subtitleStyle.Render(" v"+version),
+ )
+
inner := lipgloss.JoinVertical(
lipgloss.Left,
- titleStyle.Render("BENSON"),
- subtitleStyle.Render("a Minecraft server manager from hell"),
- "",
- mutedStyle.Render(fmt.Sprintf("Fabric · MC %s · v%s", cfg.MinecraftVersion, version)),
+ header,
+ mutedStyle.Render("Fabric · MC "+cfg.MinecraftVersion),
)
- hint := mutedStyle.Render("Type a command below. Tab autocompletes · Ctrl+C quits.")
+ hint := mutedStyle.Render("Tab autocompletes · Ctrl+C quits")
return lipgloss.JoinVertical(lipgloss.Left, bannerStyle.Render(inner), "", hint)
}
diff --git a/internal/cli/styles.go b/internal/cli/styles.go
index c0a61e5..78d5cb4 100644
--- a/internal/cli/styles.go
+++ b/internal/cli/styles.go
@@ -14,7 +14,7 @@ var (
bannerStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colPrimary).
- Padding(0, 3)
+ Padding(0, 2)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colPrimary)
subtitleStyle = lipgloss.NewStyle().Foreground(colMuted)
diff --git a/internal/modrinth/modrinth.go b/internal/modrinth/modrinth.go
new file mode 100644
index 0000000..8060f3b
--- /dev/null
+++ b/internal/modrinth/modrinth.go
@@ -0,0 +1,128 @@
+package modrinth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+)
+
+const (
+ apiBase = "https://api.modrinth.com/v2"
+ userAgent = "benson/2.0 (github.com/hachem-wtf/benson)"
+)
+
+type File struct {
+ URL string `json:"url"`
+ Filename string `json:"filename"`
+ Primary bool `json:"primary"`
+}
+
+type Version struct {
+ ID string `json:"id"`
+ VersionNumber string `json:"version_number"`
+ DatePublished time.Time `json:"date_published"`
+ GameVersions []string `json:"game_versions"`
+ Loaders []string `json:"loaders"`
+ Files []File `json:"files"`
+}
+
+type Client struct {
+ http *http.Client
+}
+
+func NewClient() *Client {
+ return &Client{http: &http.Client{Timeout: 30 * time.Second}}
+}
+
+func (c *Client) GetVersions(slug string) ([]Version, error) {
+ url := fmt.Sprintf("%s/project/%s/version", apiBase, slug)
+
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", userAgent)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("modrinth: %s returned %s", slug, resp.Status)
+ }
+
+ var versions []Version
+ if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
+ return nil, err
+ }
+ return versions, nil
+}
+
+func LatestVersion(versions []Version) *Version {
+ if len(versions) == 0 {
+ return nil
+ }
+
+ best := 0
+ for i := 1; i < len(versions); i++ {
+ if versions[i].DatePublished.After(versions[best].DatePublished) {
+ best = i
+ }
+ }
+ return &versions[best]
+}
+
+func CompatibleVersion(versions []Version, mcVersion, loader string) *Version {
+ var compatible []Version
+ for _, v := range versions {
+ if !contains(v.GameVersions, mcVersion) {
+ continue
+ }
+ if loader != "" && !contains(v.Loaders, loader) {
+ continue
+ }
+ compatible = append(compatible, v)
+ }
+ return LatestVersion(compatible)
+}
+
+func (c *Client) Download(url, dest string) error {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", userAgent)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download: %s returned %s", url, resp.Status)
+ }
+
+ f, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = io.Copy(f, resp.Body)
+ return err
+}
+
+func contains(list []string, s string) bool {
+ for _, item := range list {
+ if item == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/main.go b/main.go
index cc8d4ed..dfe0d7f 100644
--- a/main.go
+++ b/main.go
@@ -8,7 +8,7 @@ import (
"github.com/hachem-wtf/benson/internal/cli"
)
-const version = "1.1.0"
+const version = "2.0"
func usage() {
fmt.Fprint(os.Stderr, `benson — a Minecraft server manager from hell