diff options
| author | hachem <im@hachem.wtf> | 2026-08-29 18:24:42 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-29 18:24:42 +0200 |
| commit | fcbbeccaa80c7e120933682b330272a1490c1cf5 (patch) | |
| tree | 11de4610f01a3861f7bbea94da63dd2d070f420a | |
| parent | 4806ee7ef95f0f04079856f7753399b7b7aa6330 (diff) | |
refactor: unify mods command with the search
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/bot/bot.go | 20 | ||||
| -rw-r--r-- | internal/cli/check.go | 17 | ||||
| -rw-r--r-- | internal/cli/cli.go | 23 | ||||
| -rw-r--r-- | internal/cli/commands.go | 26 | ||||
| -rw-r--r-- | internal/cli/generate.go | 21 | ||||
| -rw-r--r-- | internal/cli/mods.go | 318 | ||||
| -rw-r--r-- | internal/config/cache.go | 12 | ||||
| -rw-r--r-- | internal/config/config.go | 72 | ||||
| -rw-r--r-- | internal/modrinth/modrinth.go | 47 | ||||
| -rw-r--r-- | internal/setup/mods.go | 177 | ||||
| -rw-r--r-- | internal/setup/setup.go | 69 |
13 files changed, 711 insertions, 94 deletions
@@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b github.com/charmbracelet/lipgloss v1.1.0 ) @@ -18,6 +18,8 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I= +github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 48e395b..39fd00b 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -114,17 +114,21 @@ func checkMods(cfg *config.Config, client *modrinth.Client) report { newCache := config.Cache{} var rep report - for _, slug := range cfg.ModrinthMods { - versions, err := client.GetVersions(slug) + for _, mod := range cfg.ModrinthMods { + if mod.Pinned() { + continue + } + + versions, err := client.GetVersions(mod.Slug) if err != nil { - log.Printf("[Modrinth] failed for %s: %v", slug, err) + log.Printf("[Modrinth] failed for %s: %v", mod.Slug, err) continue } latest := modrinth.LatestVersion(versions) compatible := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "") - cached := cache[slug] + cached := cache[mod.Slug] state := config.ModState{} if compatible != nil { @@ -133,15 +137,15 @@ func checkMods(cfg *config.Config, client *modrinth.Client) report { if latest != nil { state.Latest = latest.ID } - newCache[slug] = state + newCache[mod.Slug] = state switch { case compatible == nil: - rep.noCompatible = append(rep.noCompatible, slug) + rep.noCompatible = append(rep.noCompatible, mod.Slug) case cached.Compatible == "": - rep.newlyCompatible = append(rep.newlyCompatible, slug) + rep.newlyCompatible = append(rep.newlyCompatible, mod.Slug) case latest != nil && latest.ID != cached.Latest: - rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, slug) + rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, mod.Slug) } } diff --git a/internal/cli/check.go b/internal/cli/check.go index 1df5137..6d3df5f 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -33,16 +33,21 @@ func checkReport(cfg *config.Config) string { client := modrinth.NewClient() var res checkResult - for _, slug := range cfg.ModrinthMods { - versions, err := client.GetVersions(slug) + for _, mod := range cfg.ModrinthMods { + versions, err := client.GetVersions(mod.Slug) if err != nil { - res.failed = append(res.failed, slug) + res.failed = append(res.failed, mod.Slug) continue } - match := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric") + var match *modrinth.Version + if mod.Pinned() { + match = modrinth.FindVersion(versions, mod.Version) + } else { + match = modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric") + } if match == nil { - res.incompatible = append(res.incompatible, slug) + res.incompatible = append(res.incompatible, mod.Slug) continue } @@ -50,7 +55,7 @@ func checkReport(cfg *config.Config) string { if len(match.Files) > 0 { filename = match.Files[0].Filename } - res.compatible = append(res.compatible, compatMod{slug: slug, filename: filename}) + res.compatible = append(res.compatible, compatMod{slug: mod.Slug, filename: filename}) } return renderCheck(cfg, res) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4f98b6d..243dc80 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/hachem-wtf/benson/internal/config" @@ -37,7 +38,14 @@ type model struct { quitting bool events chan tea.Msg - width int + form *huh.Form + pickerStage int + pickerAction string + pickerQuery string + pickerSlug string + pickerVersion string + + width, height int } func Run(version string) error { @@ -88,9 +96,13 @@ func (m *model) Init() tea.Cmd { } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.form != nil { + return m.updatePicker(msg) + } + switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width + m.width, m.height = msg.Width, msg.Height m.input.Width = max(msg.Width-12, 20) return m, nil @@ -116,6 +128,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.busy = false return m, tea.Println(msg.text) + case modManagerMsg: + m.busy = false + return m, m.handleModManager(msg) + case streamMsg: return m, tea.Sequence(tea.Println(msg.text), waitForEvent(m.events)) @@ -145,6 +161,9 @@ func (m *model) View() string { if m.quitting { return "" } + if m.form != nil { + return "\n" + m.form.View() + "\n" + } return "\n" + m.suggestionRow() + "\n" + m.promptLine() + "\n" } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f50036c..d76402d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,7 +1,6 @@ package cli import ( - "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -13,11 +12,9 @@ import ( func defaultCommands() []command { return []command{ { - name: "list-mods", - desc: "List the configured mods", - run: func(m *model) tea.Cmd { - return tea.Println(renderModList(m.cfg)) - }, + name: "mods", + desc: "Manage mods — list, search, add, pin versions, remove", + run: runMods, }, { name: "check", @@ -76,23 +73,6 @@ func helpBlock(cmds []command) string { return lipgloss.JoinVertical(lipgloss.Left, rows...) } -func renderModList(cfg *config.Config) string { - rows := []string{ - headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)), - "", - } - - if len(cfg.ModrinthMods) == 0 { - rows = append(rows, mutedStyle.Render(" (none configured)")) - } - for _, slug := range cfg.ModrinthMods { - rows = append(rows, " "+bulletStyle.Render("◆ ")+slug) - } - - rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods)))) - return lipgloss.JoinVertical(lipgloss.Left, rows...) -} - func padRight(s string, n int) string { if pad := n - len([]rune(s)); pad > 0 { return s + strings.Repeat(" ", pad) diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 352b7b4..3e7db04 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -67,25 +67,34 @@ func generateWorker(cfg *config.Config, ch chan<- tea.Msg) { client := modrinth.NewClient() downloaded, failed := 0, 0 - for _, slug := range cfg.ModrinthMods { - versions, err := client.GetVersions(slug) + for _, mod := range cfg.ModrinthMods { + versions, err := client.GetVersions(mod.Slug) if err != nil { failed++ - emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — "+err.Error())) + emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+err.Error())) continue } - match := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric") + var match *modrinth.Version + if mod.Pinned() { + match = modrinth.FindVersion(versions, mod.Version) + } else { + match = modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric") + } if match == nil || len(match.Files) == 0 { failed++ - emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — no fabric build for MC "+cfg.MinecraftVersion)) + reason := "no fabric build for MC " + cfg.MinecraftVersion + if mod.Pinned() { + reason = "version " + mod.Version + " not found" + } + emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+reason)) continue } file := match.Files[0] if err := client.Download(file.URL, filepath.Join(modsDir, file.Filename)); err != nil { failed++ - emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — "+err.Error())) + emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+err.Error())) continue } diff --git a/internal/cli/mods.go b/internal/cli/mods.go new file mode 100644 index 0000000..b5ff93e --- /dev/null +++ b/internal/cli/mods.go @@ -0,0 +1,318 @@ +package cli + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/hachem-wtf/benson/internal/config" + "github.com/hachem-wtf/benson/internal/modrinth" +) + +const ( + stageMenu = iota + stageQuery + stageResults + stageVersion + stageRemove +) + +type modManagerMsg struct { + kind string + hits []modrinth.SearchHit + versions []modrinth.Version + query string + err error +} + +func runMods(m *model) tea.Cmd { + m.openMenu() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) updatePicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if size, ok := msg.(tea.WindowSizeMsg); ok { + m.width, m.height = size.Width, size.Height + } + + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + + switch m.form.State { + case huh.StateAborted: + if m.pickerStage == stageMenu { + m.form = nil + return m, nil + } + return m, m.reopenMenu() + case huh.StateCompleted: + return m, m.advance() + } + return m, cmd +} + +func (m *model) advance() tea.Cmd { + switch m.pickerStage { + case stageMenu: + switch m.pickerAction { + case "add": + m.openQuery() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) + case "remove": + m.openRemove() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) + default: + m.form = nil + return tea.Println(renderModList(m.cfg)) + } + + case stageQuery: + query := strings.TrimSpace(m.pickerQuery) + m.form = nil + if query == "" { + return m.reopenMenu() + } + return m.searchCmd(query) + + case stageResults: + slug := m.pickerSlug + m.form = nil + return m.versionsCmd(slug) + + case stageVersion: + m.cfg.ModrinthMods = upsertMod(m.cfg.ModrinthMods, config.Mod{Slug: m.pickerSlug, Version: m.pickerVersion}) + return m.saveThenMenu(successStyle.Render("Added ") + m.pickerSlug + mutedStyle.Render(" ("+versionTag(m.pickerVersion)+")")) + + case stageRemove: + m.cfg.ModrinthMods = removeMod(m.cfg.ModrinthMods, m.pickerSlug) + return m.saveThenMenu(successStyle.Render("Removed ") + m.pickerSlug) + } + return nil +} + +func (m *model) searchCmd(query string) tea.Cmd { + m.busy = true + mc := m.cfg.MinecraftVersion + return tea.Batch(m.spin.Tick, func() tea.Msg { + hits, err := modrinth.NewClient().Search(query, mc, "fabric") + return modManagerMsg{kind: "search", hits: hits, query: query, err: err} + }) +} + +func (m *model) versionsCmd(slug string) tea.Cmd { + m.busy = true + return tea.Batch(m.spin.Tick, func() tea.Msg { + versions, err := modrinth.NewClient().GetVersions(slug) + return modManagerMsg{kind: "versions", versions: versions, err: err} + }) +} + +func (m *model) handleModManager(msg modManagerMsg) tea.Cmd { + if msg.err != nil { + return tea.Sequence(tea.Println(errorStyle.Render("Error: "+msg.err.Error())), m.reopenMenu()) + } + + switch msg.kind { + case "search": + if len(msg.hits) == 0 { + return tea.Sequence(tea.Println(mutedStyle.Render("No results for “"+msg.query+"”.")), m.reopenMenu()) + } + m.openResults(msg.hits) + case "versions": + m.openVersion(msg.versions) + } + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) saveThenMenu(notice string) tea.Cmd { + if err := config.Save(m.cfg); err != nil { + return tea.Sequence(tea.Println(errorStyle.Render("Failed to save config: "+err.Error())), m.reopenMenu()) + } + return tea.Sequence(tea.Println(notice), m.reopenMenu()) +} + +func (m *model) reopenMenu() tea.Cmd { + m.openMenu() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) openMenu() { + m.pickerStage = stageMenu + m.pickerAction = "add" + + options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")} + if len(m.cfg.ModrinthMods) > 0 { + options = append(options, huh.NewOption("Remove a mod", "remove")) + } + options = append(options, huh.NewOption("Done", "done")) + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("Mods · %d configured", len(m.cfg.ModrinthMods))). + Description(modsSummary(m.cfg.ModrinthMods)). + Options(options...). + Value(&m.pickerAction), + )).WithTheme(pickerTheme()) +} + +func (m *model) openQuery() { + m.pickerStage = stageQuery + m.pickerQuery = "" + m.form = huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Search Modrinth"). + Description("Mod name or keyword · esc to go back"). + Value(&m.pickerQuery), + )).WithTheme(pickerTheme()) +} + +func (m *model) openResults(hits []modrinth.SearchHit) { + m.pickerStage = stageResults + m.pickerSlug = hits[0].Slug + + options := make([]huh.Option[string], len(hits)) + for i, h := range hits { + options[i] = huh.NewOption(h.Title+" ("+h.Slug+")", h.Slug) + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Add a mod"). + Description("Pick a search result · esc to go back"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerSlug), + )).WithTheme(pickerTheme()) +} + +func (m *model) openVersion(versions []modrinth.Version) { + m.pickerStage = stageVersion + m.pickerVersion = "" + + options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")} + for _, v := range versions { + if containsStr(v.Loaders, "fabric") && containsStr(v.GameVersions, m.cfg.MinecraftVersion) { + options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber)) + } + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Version for " + m.pickerSlug). + Description("Keep it on latest, or pin one"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerVersion), + )).WithTheme(pickerTheme()) +} + +func (m *model) openRemove() { + m.pickerStage = stageRemove + m.pickerSlug = m.cfg.ModrinthMods[0].Slug + + options := make([]huh.Option[string], len(m.cfg.ModrinthMods)) + for i, mod := range m.cfg.ModrinthMods { + options[i] = huh.NewOption(modLabel(mod), mod.Slug) + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Remove which mod?"). + Description("esc to go back"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerSlug), + )).WithTheme(pickerTheme()) +} + +func renderModList(cfg *config.Config) string { + rows := []string{headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)), ""} + if len(cfg.ModrinthMods) == 0 { + rows = append(rows, mutedStyle.Render(" (none configured)")) + } + for _, mod := range cfg.ModrinthMods { + tag := mutedStyle.Render(" latest") + if mod.Pinned() { + tag = warnStyle.Render(" pinned " + mod.Version) + } + rows = append(rows, " "+bulletStyle.Render("◆ ")+mod.Slug+tag) + } + rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods)))) + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func modsSummary(mods []config.Mod) string { + if len(mods) == 0 { + return "No mods yet — search Modrinth to add some." + } + var b strings.Builder + for _, mod := range mods { + fmt.Fprintf(&b, "• %s\n", modLabel(mod)) + } + return strings.TrimRight(b.String(), "\n") +} + +func modLabel(mod config.Mod) string { + if mod.Pinned() { + return mod.Slug + " (" + mod.Version + ")" + } + return mod.Slug + " (latest)" +} + +func versionTag(version string) string { + if version == "" { + return "latest" + } + return "pinned " + version +} + +func upsertMod(mods []config.Mod, mod config.Mod) []config.Mod { + for i := range mods { + if mods[i].Slug == mod.Slug { + mods[i] = mod + return mods + } + } + return append(mods, mod) +} + +func removeMod(mods []config.Mod, slug string) []config.Mod { + kept := make([]config.Mod, 0, len(mods)) + for _, mod := range mods { + if mod.Slug != slug { + kept = append(kept, mod) + } + } + return kept +} + +func selectHeight(n int) int { + return min(10, n) + 2 +} + +func containsStr(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func pickerTheme() *huh.Theme { + theme := huh.ThemeCharm() + theme.Focused.Title = theme.Focused.Title.Foreground(colAccent).Bold(true) + theme.Focused.Base = theme.Focused.Base.BorderForeground(colPrimary) + return theme +} + +func sizeCmd(w, h int) tea.Cmd { + return func() tea.Msg { + return tea.WindowSizeMsg{Width: w, Height: h} + } +} diff --git a/internal/config/cache.go b/internal/config/cache.go index 825af7f..aaaa97b 100644 --- a/internal/config/cache.go +++ b/internal/config/cache.go @@ -23,6 +23,18 @@ func LoadAPI() (*API, error) { return &a, nil } +func SaveAPI(api *API) error { + if err := os.MkdirAll(Dir, 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(api, "", " ") + if err != nil { + return err + } + return os.WriteFile(APIPath(), append(data, '\n'), 0o600) +} + type ModState struct { Compatible string `json:"compatible"` Latest string `json:"latest"` diff --git a/internal/config/config.go b/internal/config/config.go index 35842a1..05d96ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,17 +7,55 @@ import ( ) type Config struct { - ChannelID string `json:"channel_id"` - CheckIntervalMinutes int `json:"check_interval_minutes"` - MinecraftVersion string `json:"minecraft_version"` - FabricLoaderVersion string `json:"fabric_loader_version"` - LoaderMetaVersion string `json:"loader_meta_version"` - JavaPath string `json:"java_path"` - ServerLocation string `json:"server_location"` - BackupLocation string `json:"backup_location"` - MergePreCommand string `json:"merge_pre_command"` - MergePostCommand string `json:"merge_post_command"` - ModrinthMods []string `json:"modrinth_mods"` + ChannelID string `json:"channel_id"` + CheckIntervalMinutes int `json:"check_interval_minutes"` + MinecraftVersion string `json:"minecraft_version"` + FabricLoaderVersion string `json:"fabric_loader_version"` + LoaderMetaVersion string `json:"loader_meta_version"` + JavaPath string `json:"java_path"` + ServerLocation string `json:"server_location"` + BackupLocation string `json:"backup_location"` + MergePreCommand string `json:"merge_pre_command"` + MergePostCommand string `json:"merge_post_command"` + ModrinthMods []Mod `json:"modrinth_mods"` +} + +type Mod struct { + Slug string + Version string +} + +func (m Mod) Pinned() bool { + return m.Version != "" +} + +func (m Mod) MarshalJSON() ([]byte, error) { + if m.Version == "" { + return json.Marshal(m.Slug) + } + return json.Marshal(modObject{Slug: m.Slug, Version: m.Version}) +} + +func (m *Mod) UnmarshalJSON(data []byte) error { + var slug string + if err := json.Unmarshal(data, &slug); err == nil { + m.Slug = slug + m.Version = "" + return nil + } + + var obj modObject + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + m.Slug = obj.Slug + m.Version = obj.Version + return nil +} + +type modObject struct { + Slug string `json:"slug"` + Version string `json:"version"` } const Dir = "config" @@ -43,3 +81,15 @@ func Load() (*Config, error) { return &c, nil } + +func Save(cfg *Config) error { + if err := os.MkdirAll(Dir, 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(Path(), append(data, '\n'), 0o644) +} diff --git a/internal/modrinth/modrinth.go b/internal/modrinth/modrinth.go index 8060f3b..a6ed9eb 100644 --- a/internal/modrinth/modrinth.go +++ b/internal/modrinth/modrinth.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "time" ) @@ -37,6 +38,43 @@ func NewClient() *Client { return &Client{http: &http.Client{Timeout: 30 * time.Second}} } +type SearchHit struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Downloads int `json:"downloads"` +} + +func (c *Client) Search(query, mcVersion, loader string) ([]SearchHit, error) { + facets := fmt.Sprintf(`[["project_type:mod"],["categories:%s"],["versions:%s"]]`, loader, mcVersion) + endpoint := fmt.Sprintf("%s/search?limit=20&index=relevance&query=%s&facets=%s", + apiBase, url.QueryEscape(query), url.QueryEscape(facets)) + + req, err := http.NewRequest(http.MethodGet, endpoint, 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: search returned %s", resp.Status) + } + + var result struct { + Hits []SearchHit `json:"hits"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Hits, nil +} + func (c *Client) GetVersions(slug string) ([]Version, error) { url := fmt.Sprintf("%s/project/%s/version", apiBase, slug) @@ -77,6 +115,15 @@ func LatestVersion(versions []Version) *Version { return &versions[best] } +func FindVersion(versions []Version, versionNumber string) *Version { + for i := range versions { + if versions[i].VersionNumber == versionNumber { + return &versions[i] + } + } + return nil +} + func CompatibleVersion(versions []Version, mcVersion, loader string) *Version { var compatible []Version for _, v := range versions { diff --git a/internal/setup/mods.go b/internal/setup/mods.go new file mode 100644 index 0000000..f8de840 --- /dev/null +++ b/internal/setup/mods.go @@ -0,0 +1,177 @@ +package setup + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/huh/spinner" + + "github.com/hachem-wtf/benson/internal/config" + "github.com/hachem-wtf/benson/internal/modrinth" +) + +func pickMods(existing []config.Mod, mc, loader string) []config.Mod { + mods := append([]config.Mod{}, existing...) + client := modrinth.NewClient() + + for { + options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")} + if len(mods) > 0 { + options = append(options, huh.NewOption("Remove a mod", "remove")) + } + options = append(options, huh.NewOption("Done", "done")) + + var action string + menu := huh.NewSelect[string](). + Title(fmt.Sprintf("Mods · %d configured", len(mods))). + Description(modsSummary(mods)). + Options(options...). + Value(&action) + + if err := runSub(menu); err != nil { + return mods + } + + switch action { + case "done": + return mods + case "add": + mods = addMod(mods, client, mc, loader) + case "remove": + mods = removeMod(mods) + } + } +} + +func addMod(mods []config.Mod, client *modrinth.Client, mc, loader string) []config.Mod { + var query string + if err := runSub(huh.NewInput().Title("Search Modrinth").Description("Mod name or keyword").Value(&query)); err != nil { + return mods + } + query = strings.TrimSpace(query) + if query == "" { + return mods + } + + var hits []modrinth.SearchHit + _ = spinner.New().Title("Searching Modrinth…").Action(func() { + hits, _ = client.Search(query, mc, loader) + }).Run() + + if len(hits) == 0 { + _ = runSub(huh.NewNote().Title("No results").Description("Nothing matched “" + query + "” for MC " + mc + ".")) + return mods + } + + options := make([]huh.Option[string], len(hits)) + for i, h := range hits { + options[i] = huh.NewOption(fmt.Sprintf("%s (%s)", h.Title, h.Slug), h.Slug) + } + + var slug string + if err := runSub(huh.NewSelect[string]().Title("Results for “" + query + "”").Options(options...).Height(selectHeight(len(options))).Value(&slug)); err != nil { + return mods + } + if slug == "" { + return mods + } + + return upsertMod(mods, config.Mod{Slug: slug, Version: pickVersion(client, slug, mc, loader)}) +} + +func pickVersion(client *modrinth.Client, slug, mc, loader string) string { + var versions []modrinth.Version + _ = spinner.New().Title("Loading versions…").Action(func() { + versions, _ = client.GetVersions(slug) + }).Run() + + options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")} + for _, v := range versions { + if compatibleWith(v, mc, loader) { + options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber)) + } + } + + var chosen string + _ = runSub(huh.NewSelect[string](). + Title("Version for " + slug). + Description("Keep it on latest, or pin a specific version"). + Options(options...). + Height(selectHeight(len(options))). + Value(&chosen)) + return chosen +} + +func removeMod(mods []config.Mod) []config.Mod { + options := make([]huh.Option[string], len(mods)) + for i, m := range mods { + options[i] = huh.NewOption(modLabel(m), m.Slug) + } + + var slug string + if err := runSub(huh.NewSelect[string]().Title("Remove which mod?").Options(options...).Height(selectHeight(len(options))).Value(&slug)); err != nil { + return mods + } + if slug == "" { + return mods + } + + kept := make([]config.Mod, 0, len(mods)) + for _, m := range mods { + if m.Slug != slug { + kept = append(kept, m) + } + } + return kept +} + +func runSub(field huh.Field) error { + return huh.NewForm(huh.NewGroup(field)).WithTheme(emberTheme()).Run() +} + +func upsertMod(mods []config.Mod, mod config.Mod) []config.Mod { + for i := range mods { + if mods[i].Slug == mod.Slug { + mods[i] = mod + return mods + } + } + return append(mods, mod) +} + +func compatibleWith(v modrinth.Version, mc, loader string) bool { + hasMC, hasLoader := false, false + for _, g := range v.GameVersions { + if g == mc { + hasMC = true + break + } + } + for _, l := range v.Loaders { + if l == loader { + hasLoader = true + break + } + } + return hasMC && hasLoader +} + +func modLabel(m config.Mod) string { + if m.Pinned() { + return m.Slug + " (" + m.Version + ")" + } + return m.Slug + " (latest)" +} + +func modsSummary(mods []config.Mod) string { + if len(mods) == 0 { + return "No mods yet — search Modrinth to add some." + } + + var b strings.Builder + for _, m := range mods { + fmt.Fprintf(&b, "• %s\n", modLabel(m)) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index c2f8c10..c3c957c 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -27,46 +27,49 @@ func Run() error { backupLoc = cfg.BackupLocation preCmd = cfg.MergePreCommand postCmd = cfg.MergePostCommand - modsText = strings.Join(cfg.ModrinthMods, "\n") - confirm = true ) games, loaders, installers := fetchFabricVersions() form := huh.NewForm( huh.NewGroup( - huh.NewNote(). - Title("Benson setup"). - Description("Configure the server manager.\nThe Discord bot is optional — run `benson setup bot` for that."), - ), - huh.NewGroup( - versionField("Minecraft version", "The Minecraft release your server runs — newest stable is recommended", games, &mcVersion, "1.21.10"), + versionField("Minecraft version", "Which release your server runs · newest recommended", games, &mcVersion, "1.21.10"), ), huh.NewGroup( - versionField("Fabric loader version", "The Fabric mod loader that boots your mods — newest is recommended", loaders, &fabricLoader, "0.17.3"), + versionField("Fabric loader version", "The mod loader that boots your mods · newest recommended", loaders, &fabricLoader, "0.17.3"), ), huh.NewGroup( - versionField("Fabric installer version", "Builds the server jar from the loader — newest is recommended", installers, &loaderMeta, "1.1.0"), + versionField("Fabric installer version", "Builds the server jar · newest recommended", installers, &loaderMeta, "1.1.0"), ), huh.NewGroup( - huh.NewInput().Title("Java path").Description("Leave blank to use 'java' from PATH").Value(&javaPath), - huh.NewInput().Title("Server location").Description("Live server directory, merged into by 'merge'").Value(&serverLoc), - huh.NewInput().Title("Backup location").Description("Where server backups are written").Value(&backupLoc), - huh.NewInput().Title("Pre-merge command").Description("Optional shell command run before merging").Value(&preCmd), - huh.NewInput().Title("Post-merge command").Description("Optional shell command run after merging").Value(&postCmd), - ).Title("Paths & hooks (optional)"), + huh.NewInput().Title("Java path").Description("Blank = use 'java' from PATH").Value(&javaPath), + huh.NewInput().Title("Server location").Description("Live server dir, merged into by 'merge'").Value(&serverLoc), + huh.NewInput().Title("Backup location").Description("Where backups are written").Value(&backupLoc), + ).Title("Locations (optional)"), huh.NewGroup( - huh.NewText().Title("Modrinth mods").Description("One mod slug per line").Lines(8).Value(&modsText), - ).Title("Mods"), - huh.NewGroup( - huh.NewConfirm().Title("Write config/config.json?").Affirmative("Write").Negative("Cancel").Value(&confirm), - ), + huh.NewInput().Title("Pre-merge command").Description("Optional shell command before merging").Value(&preCmd), + huh.NewInput().Title("Post-merge command").Description("Optional shell command after merging").Value(&postCmd), + ).Title("Merge hooks (optional)"), ) if done, err := runForm(form); err != nil || !done { return err } + mods := pickMods(cfg.ModrinthMods, strings.TrimSpace(mcVersion), "fabric") + + confirm := true + confirmForm := huh.NewForm(huh.NewGroup( + huh.NewConfirm().Title("Write config/config.json?").Affirmative("Write").Negative("Cancel").Value(&confirm), + )) + if done, err := runForm(confirmForm); err != nil || !done { + return err + } + if !confirm { + fmt.Println("Setup cancelled — nothing written.") + return nil + } + cfg.MinecraftVersion = strings.TrimSpace(mcVersion) cfg.FabricLoaderVersion = strings.TrimSpace(fabricLoader) cfg.LoaderMetaVersion = strings.TrimSpace(loaderMeta) @@ -75,7 +78,7 @@ func Run() error { cfg.BackupLocation = strings.TrimSpace(backupLoc) cfg.MergePreCommand = strings.TrimSpace(preCmd) cfg.MergePostCommand = strings.TrimSpace(postCmd) - cfg.ModrinthMods = parseMods(modsText) + cfg.ModrinthMods = mods if err := writeConfigFile(cfg); err != nil { return err @@ -106,14 +109,10 @@ func RunBot() error { huh.NewGroup( huh.NewNote(). Title("Benson bot setup"). - Description("Configure the Discord bot that posts Modrinth compatibility reports."), - ), - huh.NewGroup( + Description("Posts Modrinth compatibility reports to a Discord channel."), huh.NewInput().Title("Discord bot token").Description("Developer Portal → Bot → Reset/Copy Token").Password(true).Value(&token).Validate(required("token")), huh.NewInput().Title("Channel ID").Description("Channel where reports are posted").Value(&channelID).Validate(required("channel ID")), huh.NewInput().Title("Check interval (minutes)").Value(&intervalStr).Validate(positiveInt), - ).Title("Discord"), - huh.NewGroup( huh.NewConfirm().Title("Write config/config.json and config/api.json?").Affirmative("Write").Negative("Cancel").Value(&confirm), ), ) @@ -176,10 +175,14 @@ func versionField(title, desc string, options []string, value *string, placehold Title(title). Description(desc). Options(recommendedOptions(options)...). - Height(12). + Height(selectHeight(len(options))). Value(value) } +func selectHeight(n int) int { + return min(10, n) + 2 +} + func recommendedOptions(versions []string) []huh.Option[string] { options := make([]huh.Option[string], len(versions)) for i, v := range versions { @@ -243,16 +246,6 @@ func positiveInt(s string) error { return nil } -func parseMods(text string) []string { - mods := []string{} - for _, line := range strings.Split(text, "\n") { - if slug := strings.TrimSpace(line); slug != "" { - mods = append(mods, slug) - } - } - return mods -} - func check() string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render("✓") } |
