package setup import ( "encoding/json" "errors" "fmt" "os" "strconv" "strings" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/hachem-wtf/benson/internal/config" "github.com/hachem-wtf/benson/internal/fabric" ) func Run() error { cfg := loadConfig() var ( mcVersion = cfg.MinecraftVersion fabricLoader = cfg.FabricLoaderVersion loaderMeta = cfg.LoaderMetaVersion javaPath = cfg.JavaPath serverLoc = cfg.ServerLocation 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"), ), 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), huh.NewInput().Title("Post-merge command").Description("Optional shell command run after merging").Value(&postCmd), ).Title("Paths & hooks (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), ), ) if done, err := runForm(form); err != nil || !done { return err } cfg.MinecraftVersion = strings.TrimSpace(mcVersion) cfg.FabricLoaderVersion = strings.TrimSpace(fabricLoader) cfg.LoaderMetaVersion = strings.TrimSpace(loaderMeta) cfg.JavaPath = strings.TrimSpace(javaPath) cfg.ServerLocation = strings.TrimSpace(serverLoc) cfg.BackupLocation = strings.TrimSpace(backupLoc) cfg.MergePreCommand = strings.TrimSpace(preCmd) cfg.MergePostCommand = strings.TrimSpace(postCmd) cfg.ModrinthMods = parseMods(modsText) if err := writeConfigFile(cfg); err != nil { return err } fmt.Printf("\n %s Wrote %s\n\n Run `benson` for the CLI, or `benson setup bot` to set up the Discord bot.\n\n", check(), config.Path()) return nil } func RunBot() error { cfg := loadConfig() api := loadAPI() interval := cfg.CheckIntervalMinutes if interval <= 0 { interval = 60 } var ( token = api.Token channelID = cfg.ChannelID intervalStr = strconv.Itoa(interval) confirm = true ) form := huh.NewForm( huh.NewGroup( huh.NewNote(). Title("Benson bot setup"). Description("Configure the Discord bot that posts Modrinth compatibility reports."), ), huh.NewGroup( 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), ), ) if done, err := runForm(form); err != nil || !done { return err } minutes, _ := strconv.Atoi(strings.TrimSpace(intervalStr)) cfg.ChannelID = strings.TrimSpace(channelID) cfg.CheckIntervalMinutes = minutes api.Token = strings.TrimSpace(token) if err := writeConfigFile(cfg); err != nil { return err } if err := writeAPIFile(api); err != nil { return err } fmt.Printf("\n %s Wrote %s\n %s Wrote %s\n\n Run `benson bot` to start the bot.\n\n", check(), config.Path(), check(), config.APIPath()) 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 } return &config.Config{} } func loadAPI() *config.API { if api, err := config.LoadAPI(); err == nil { return api } return &config.API{} } func runForm(form *huh.Form) (bool, error) { if err := form.WithTheme(emberTheme()).Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { fmt.Println("Setup cancelled.") return false, nil } return false, err } return true, nil } func required(label string) func(string) error { return func(s string) error { if strings.TrimSpace(s) == "" { return fmt.Errorf("%s is required", label) } return nil } } func positiveInt(s string) error { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || n <= 0 { return errors.New("must be a positive number") } 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("✓") } func emberTheme() *huh.Theme { theme := huh.ThemeCharm() ember := lipgloss.Color("#F97316") theme.Focused.Title = theme.Focused.Title.Foreground(ember).Bold(true) theme.Focused.Base = theme.Focused.Base.BorderForeground(ember) return theme } func writeConfigFile(cfg *config.Config) error { if err := os.MkdirAll(config.Dir, 0o755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(config.Path(), append(data, '\n'), 0o644) } func writeAPIFile(api *config.API) error { if err := os.MkdirAll(config.Dir, 0o755); err != nil { return err } data, err := json.MarshalIndent(api, "", " ") if err != nil { return err } return os.WriteFile(config.APIPath(), append(data, '\n'), 0o600) }