hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 03:56:34 +0200
committerhachem <im@hachem.wtf>2026-08-29 03:56:34 +0200
commit2c22479b452b1fad9bc29c2a544088b662189d83 (patch)
tree7aa8395f6b750b0b7bba5fe8eccf66931e55fb3a
parenta221b4c5edd3ff4d4a9d74113d9d3b8c7473133d (diff)
add: generate
-rw-r--r--internal/cli/cli.go12
-rw-r--r--internal/cli/commands.go5
-rw-r--r--internal/cli/generate.go191
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
+}