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" ) // 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() 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 ) 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( 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"), huh.NewGroup( 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 } // 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() 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 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{} } // 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) { 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) }