diff options
| author | hachem <im@hachem.wtf> | 2026-08-29 03:29:51 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-29 03:29:51 +0200 |
| commit | a221b4c5edd3ff4d4a9d74113d9d3b8c7473133d (patch) | |
| tree | d902da32c84dad3d5fe0152be07d3bea6d4f9ee1 | |
| parent | f60177934a18daea3b24a37cf109bb2c7bfaaa72 (diff) | |
add: modrinth client and check command
| -rw-r--r-- | internal/cli/check.go | 100 | ||||
| -rw-r--r-- | internal/cli/cli.go | 3 | ||||
| -rw-r--r-- | internal/cli/commands.go | 19 | ||||
| -rw-r--r-- | internal/cli/styles.go | 2 | ||||
| -rw-r--r-- | internal/modrinth/modrinth.go | 128 | ||||
| -rw-r--r-- | main.go | 2 |
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 +} @@ -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 |
