diff options
| author | hachem <im@hachem.wtf> | 2026-08-29 03:56:34 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-29 03:56:34 +0200 |
| commit | 2c22479b452b1fad9bc29c2a544088b662189d83 (patch) | |
| tree | 7aa8395f6b750b0b7bba5fe8eccf66931e55fb3a | |
| parent | a221b4c5edd3ff4d4a9d74113d9d3b8c7473133d (diff) | |
add: generate
| -rw-r--r-- | internal/cli/cli.go | 12 | ||||
| -rw-r--r-- | internal/cli/commands.go | 5 | ||||
| -rw-r--r-- | internal/cli/generate.go | 191 |
3 files changed, 208 insertions, 0 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6609a7e..fecf64b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -32,6 +32,7 @@ type model struct { commands []command busy bool quitting bool + events chan tea.Msg width int } @@ -100,6 +101,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.busy = false return m, tea.Println(msg.text) + case streamMsg: + return m, tea.Sequence(tea.Println(msg.text), waitForEvent(m.events)) + + case streamDoneMsg: + m.busy = false + m.events = nil + if msg.text == "" { + return m, nil + } + return m, tea.Println(msg.text) + case spinner.TickMsg: if !m.busy { return m, nil diff --git a/internal/cli/commands.go b/internal/cli/commands.go index cf7cc90..f66ff62 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -25,6 +25,11 @@ func defaultCommands() []command { run: runCheck, }, { + name: "generate", + desc: "Download the server and mods and generate a server directory", + run: runGenerate, + }, + { name: "help", desc: "Show available commands", run: func(m *model) tea.Cmd { diff --git a/internal/cli/generate.go b/internal/cli/generate.go new file mode 100644 index 0000000..352b7b4 --- /dev/null +++ b/internal/cli/generate.go @@ -0,0 +1,191 @@ +package cli + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hachem-wtf/benson/internal/config" + "github.com/hachem-wtf/benson/internal/modrinth" +) + +var ( + serverDir = "server" + modsDir = filepath.Join("server", "mods") +) + +var postGenerateDeleteList = []string{ + "audioplayer_uploads", + "config", + "cache", + "world", + "logs", + "banned-players.json", + "server.properties", + "banned-ips.json", + "usercache.json", + "whitelist.json", + "ops.json", + "eula.txt", +} + +type streamMsg struct{ text string } + +type streamDoneMsg struct{ text string } + +func waitForEvent(ch chan tea.Msg) tea.Cmd { + return func() tea.Msg { + return <-ch + } +} + +func runGenerate(m *model) tea.Cmd { + m.busy = true + ch := make(chan tea.Msg, 256) + m.events = ch + cfg := m.cfg + go generateWorker(cfg, ch) + return tea.Batch(m.spin.Tick, waitForEvent(ch)) +} + +func generateWorker(cfg *config.Config, ch chan<- tea.Msg) { + emit := func(s string) { ch <- streamMsg{s} } + + emit(headingStyle.Render("Generating server for MC " + cfg.MinecraftVersion)) + + if err := os.MkdirAll(modsDir, 0o755); err != nil { + ch <- streamDoneMsg{errorStyle.Render("Failed to create directories: " + err.Error())} + return + } + + client := modrinth.NewClient() + + downloaded, failed := 0, 0 + for _, slug := range cfg.ModrinthMods { + versions, err := client.GetVersions(slug) + if err != nil { + failed++ + emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — "+err.Error())) + continue + } + + 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)) + 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())) + continue + } + + downloaded++ + emit(" " + successStyle.Render("✓ ") + file.Filename) + } + emit(mutedStyle.Render(fmt.Sprintf("Downloaded %d mods · %d failed", downloaded, failed))) + + emit(headingStyle.Render("Downloading Fabric server")) + serverURL := fmt.Sprintf( + "https://meta.fabricmc.net/v2/versions/loader/%s/%s/%s/server/jar", + cfg.MinecraftVersion, cfg.FabricLoaderVersion, cfg.LoaderMetaVersion, + ) + if err := client.Download(serverURL, filepath.Join(serverDir, "server.jar")); err != nil { + ch <- streamDoneMsg{errorStyle.Render("Failed to download Fabric server: " + err.Error())} + return + } + emit(" " + successStyle.Render("✓ ") + "server.jar") + + if err := runServerAutoStop(cfg, emit); err != nil { + ch <- streamDoneMsg{errorStyle.Render("Server run failed: " + err.Error())} + return + } + + removed := deletePostGenerate() + emit(mutedStyle.Render(fmt.Sprintf("Removed %d generated items", removed))) + + ch <- streamDoneMsg{successStyle.Render("Generation complete.")} +} + +func runServerAutoStop(cfg *config.Config, emit func(string)) error { + eulaPath := filepath.Join(serverDir, "eula.txt") + if _, err := os.Stat(eulaPath); os.IsNotExist(err) { + if err := os.WriteFile(eulaPath, []byte("eula=true\n"), 0o644); err != nil { + return err + } + } + + java := cfg.JavaPath + if java == "" { + java = "java" + } + + emit(headingStyle.Render("Starting server (auto-stops after loading)")) + + cmd := exec.Command(java, "-jar", "server.jar", "nogui") + cmd.Dir = serverDir + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + pr, pw, err := os.Pipe() + if err != nil { + return err + } + cmd.Stdout = pw + cmd.Stderr = pw + + if err := cmd.Start(); err != nil { + pw.Close() + return err + } + pw.Close() + + scanner := bufio.NewScanner(pr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + stopped := false + for scanner.Scan() { + line := scanner.Text() + emit(mutedStyle.Render(" │ " + line)) + + if !stopped && strings.Contains(line, "Done") { + stopped = true + emit(successStyle.Render(" Server loaded, stopping…")) + io.WriteString(stdin, "stop\n") + } + } + stdin.Close() + + if err := cmd.Wait(); err != nil && !stopped { + return err + } + + emit(successStyle.Render(" Server stopped.")) + return nil +} + +func deletePostGenerate() int { + removed := 0 + for _, item := range postGenerateDeleteList { + path := filepath.Join(serverDir, item) + if _, err := os.Stat(path); err != nil { + continue + } + if err := os.RemoveAll(path); err == nil { + removed++ + } + } + return removed +} |
