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 ) games, loaders, installers := fetchFabricVersions() form := huh.NewForm( huh.NewGroup( versionField("Minecraft version", "Which release your server runs · newest recommended", games, &mcVersion, "1.21.10"), ), huh.NewGroup( 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 · newest recommended", installers, &loaderMeta, "1.1.0"), ), huh.NewGroup( 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.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) 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 = mods 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("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), 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(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 { 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 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) }