hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 11:26:09 +0200
committerhachem <im@hachem.wtf>2026-08-29 11:26:09 +0200
commit4806ee7ef95f0f04079856f7753399b7b7aa6330 (patch)
tree5766e1f8cd714ae76b2f81da4ab3045e7193b256
parentd8b2d27b1f1ccccfe26fe0d45dbb624d453272d3 (diff)
feat: recommended option filters
-rw-r--r--internal/bot/bot.go4
-rw-r--r--internal/cli/cli.go16
-rw-r--r--internal/fabric/fabric.go79
-rw-r--r--internal/setup/setup.go84
4 files changed, 168 insertions, 15 deletions
diff --git a/internal/bot/bot.go b/internal/bot/bot.go
index 4fb8fc8..48e395b 100644
--- a/internal/bot/bot.go
+++ b/internal/bot/bot.go
@@ -152,8 +152,8 @@ func checkMods(cfg *config.Config, client *modrinth.Client) report {
}
const (
- colorGood = 0x22C55E // green — everything compatible
- colorWarn = 0xEF4444 // red — something has no compatible build
+ colorGood = 0x22C55E
+ colorWarn = 0xEF4444
)
func buildEmbed(cfg *config.Config, rep report) *discordgo.MessageEmbed {
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 7e7705e..4f98b6d 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/setup"
)
type command struct {
@@ -42,10 +43,19 @@ type model struct {
func Run(version string) error {
cfg, err := config.Load()
if err != nil {
- if os.IsNotExist(err) {
- return fmt.Errorf("no config found — run `benson setup` to create one")
+ if !os.IsNotExist(err) {
+ return fmt.Errorf("failed to load %s: %w", config.Path(), err)
+ }
+
+ fmt.Println("No configuration found — let's set one up.")
+ if err := setup.Run(); err != nil {
+ return err
+ }
+
+ cfg, err = config.Load()
+ if err != nil {
+ return fmt.Errorf("setup did not complete — run `benson setup` to configure")
}
- return fmt.Errorf("failed to load %s: %w", config.Path(), err)
}
_, err = tea.NewProgram(newModel(cfg, version)).Run()
diff --git a/internal/fabric/fabric.go b/internal/fabric/fabric.go
new file mode 100644
index 0000000..3e52e6f
--- /dev/null
+++ b/internal/fabric/fabric.go
@@ -0,0 +1,79 @@
+package fabric
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+const (
+ apiBase = "https://meta.fabricmc.net/v2"
+ userAgent = "benson/2.0 (github.com/hachem-wtf/benson)"
+)
+
+type GameVersion struct {
+ Version string `json:"version"`
+ Stable bool `json:"stable"`
+}
+
+type LoaderVersion struct {
+ Version string `json:"version"`
+ Stable bool `json:"stable"`
+}
+
+type InstallerVersion struct {
+ Version string `json:"version"`
+ Stable bool `json:"stable"`
+}
+
+type Client struct {
+ http *http.Client
+}
+
+func NewClient() *Client {
+ return &Client{http: &http.Client{Timeout: 30 * time.Second}}
+}
+
+func (c *Client) get(path string, out any) error {
+ req, err := http.NewRequest(http.MethodGet, apiBase+path, 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("fabric: %s returned %s", path, resp.Status)
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func (c *Client) GameVersions() ([]GameVersion, error) {
+ var versions []GameVersion
+ if err := c.get("/versions/game", &versions); err != nil {
+ return nil, err
+ }
+ return versions, nil
+}
+
+func (c *Client) LoaderVersions() ([]LoaderVersion, error) {
+ var versions []LoaderVersion
+ if err := c.get("/versions/loader", &versions); err != nil {
+ return nil, err
+ }
+ return versions, nil
+}
+
+func (c *Client) InstallerVersions() ([]InstallerVersion, error) {
+ var versions []InstallerVersion
+ if err := c.get("/versions/installer", &versions); err != nil {
+ return nil, err
+ }
+ return versions, nil
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index eafaaa0..c2f8c10 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -12,10 +12,9 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/fabric"
)
-// Run is the server-manager wizard. It configures Minecraft/Fabric versions,
-// paths, merge hooks and mods — everything except the Discord bot.
func Run() error {
cfg := loadConfig()
@@ -32,6 +31,8 @@ func Run() error {
confirm = true
)
+ games, loaders, installers := fetchFabricVersions()
+
form := huh.NewForm(
huh.NewGroup(
huh.NewNote().
@@ -39,12 +40,16 @@ func Run() error {
Description("Configure the server manager.\nThe Discord bot is optional — run `benson setup bot` for that."),
),
huh.NewGroup(
- huh.NewInput().Title("Minecraft version").Placeholder("1.21.10").Value(&mcVersion).Validate(required("Minecraft version")),
- huh.NewInput().Title("Fabric loader version").Placeholder("0.17.3").Value(&fabricLoader).Validate(required("Fabric loader version")),
- huh.NewInput().Title("Loader / installer version").Placeholder("1.1.0").Value(&loaderMeta).Validate(required("loader version")),
- huh.NewInput().Title("Java path").Description("Leave blank to use 'java' from PATH").Value(&javaPath),
- ).Title("Minecraft / Fabric"),
+ versionField("Minecraft version", "The Minecraft release your server runs — newest stable is 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"),
+ ),
+ huh.NewGroup(
+ versionField("Fabric installer version", "Builds the server jar from the loader — newest is 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),
@@ -81,8 +86,6 @@ func Run() error {
return nil
}
-// RunBot is the Discord-bot wizard. It configures the bot token, channel and
-// check interval, leaving the server-manager settings untouched.
func RunBot() error {
cfg := loadConfig()
api := loadAPI()
@@ -136,6 +139,68 @@ func RunBot() error {
return nil
}
+func fetchFabricVersions() (games, loaders, installers []string) {
+ client := fabric.NewClient()
+
+ if gv, err := client.GameVersions(); err == nil {
+ for _, v := range gv {
+ if v.Stable {
+ games = append(games, v.Version)
+ }
+ }
+ }
+ if lv, err := client.LoaderVersions(); err == nil {
+ for _, v := range lv {
+ loaders = append(loaders, v.Version)
+ }
+ }
+ if iv, err := client.InstallerVersions(); err == nil {
+ for _, v := range iv {
+ installers = append(installers, v.Version)
+ }
+ }
+ return games, loaders, installers
+}
+
+func versionField(title, desc string, options []string, value *string, placeholder string) huh.Field {
+ if len(options) == 0 {
+ return huh.NewInput().
+ Title(title).
+ Placeholder(placeholder).
+ Value(value).
+ Validate(required(title))
+ }
+
+ *value = defaultOr(*value, options)
+ return huh.NewSelect[string]().
+ Title(title).
+ Description(desc).
+ Options(recommendedOptions(options)...).
+ Height(12).
+ Value(value)
+}
+
+func recommendedOptions(versions []string) []huh.Option[string] {
+ options := make([]huh.Option[string], len(versions))
+ for i, v := range versions {
+ label := v
+ if i == 0 {
+ label = v + " · recommended"
+ }
+ options[i] = huh.NewOption(label, v)
+ }
+ return options
+}
+
+func defaultOr(current string, options []string) string {
+ for _, o := range options {
+ if o == current {
+ return current
+ }
+ }
+ return options[0]
+}
+
func loadConfig() *config.Config {
if cfg, err := config.Load(); err == nil {
return cfg
@@ -150,7 +215,6 @@ func loadAPI() *config.API {
return &config.API{}
}
-// runForm runs a themed form, reporting whether the user confirmed the write.
func runForm(form *huh.Form) (bool, error) {
if err := form.WithTheme(emberTheme()).Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {