hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 04:35:17 +0200
committerhachem <im@hachem.wtf>2026-08-29 04:35:17 +0200
commit6d44318d728da84e5018bbbdbf69dc3053ffe133 (patch)
treeaf59f1ecb98720b7f975f97c372d17daff5be419
parent2c22479b452b1fad9bc29c2a544088b662189d83 (diff)
add: merge command
-rw-r--r--internal/cli/commands.go5
-rw-r--r--internal/cli/merge.go247
2 files changed, 252 insertions, 0 deletions
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index f66ff62..fc620c6 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -30,6 +30,11 @@ func defaultCommands() []command {
run: runGenerate,
},
{
+ name: "merge",
+ desc: "Back up the existing server and merge the generated files into it",
+ run: runMerge,
+ },
+ {
name: "help",
desc: "Show available commands",
run: func(m *model) tea.Cmd {
diff --git a/internal/cli/merge.go b/internal/cli/merge.go
new file mode 100644
index 0000000..fbae597
--- /dev/null
+++ b/internal/cli/merge.go
@@ -0,0 +1,247 @@
+package cli
+
+import (
+ "archive/zip"
+ "bufio"
+ "compress/flate"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/hachem-wtf/benson/internal/config"
+)
+
+func runMerge(m *model) tea.Cmd {
+ m.busy = true
+ ch := make(chan tea.Msg, 256)
+ m.events = ch
+ cfg := m.cfg
+ go mergeWorker(cfg, ch)
+ return tea.Batch(m.spin.Tick, waitForEvent(ch))
+}
+
+func mergeWorker(cfg *config.Config, ch chan<- tea.Msg) {
+ emit := func(s string) { ch <- streamMsg{s} }
+ done := func(s string) { ch <- streamDoneMsg{s} }
+
+ if cfg.ServerLocation == "" || cfg.BackupLocation == "" {
+ done(errorStyle.Render("server_location and backup_location must be set in config."))
+ return
+ }
+ if _, err := os.Stat(serverDir); err != nil {
+ done(errorStyle.Render("No generated files found. Run 'generate' first."))
+ return
+ }
+
+ emit(headingStyle.Render("Merging generated server"))
+
+ if cfg.MergePreCommand != "" {
+ emit(mutedStyle.Render("Running pre-merge command…"))
+ if err := runShellCommand(cfg.MergePreCommand, emit); err != nil {
+ emit(warnStyle.Render(" pre-command exited: " + err.Error()))
+ }
+ }
+
+ if _, err := os.Stat(cfg.ServerLocation); err == nil {
+ emit(mutedStyle.Render("Backing up " + cfg.ServerLocation + "…"))
+ name, err := createBackup(cfg.ServerLocation, cfg.BackupLocation)
+ if err != nil {
+ done(errorStyle.Render("Failed to create backup: " + err.Error()))
+ return
+ }
+ emit(" " + successStyle.Render("✓ ") + name)
+ } else {
+ emit(mutedStyle.Render("Server location doesn't exist, creating it…"))
+ if err := os.MkdirAll(cfg.ServerLocation, 0o755); err != nil {
+ done(errorStyle.Render("Failed to create server location: " + err.Error()))
+ return
+ }
+ }
+
+ emit(mutedStyle.Render("Moving generated files into place…"))
+ entries, err := os.ReadDir(serverDir)
+ if err != nil {
+ done(errorStyle.Render("Failed to read generated directory: " + err.Error()))
+ return
+ }
+ for _, e := range entries {
+ src := filepath.Join(serverDir, e.Name())
+ dst := filepath.Join(cfg.ServerLocation, e.Name())
+
+ os.RemoveAll(dst)
+ if err := movePath(src, dst); err != nil {
+ done(errorStyle.Render("Failed to move " + e.Name() + ": " + err.Error()))
+ return
+ }
+ emit(" " + successStyle.Render("moved ") + e.Name())
+ }
+ os.RemoveAll(serverDir)
+ emit(successStyle.Render("Merge complete."))
+
+ if cfg.MergePostCommand != "" {
+ emit(mutedStyle.Render("Running post-merge command…"))
+ if err := runShellCommand(cfg.MergePostCommand, emit); err != nil {
+ emit(warnStyle.Render(" post-command exited: " + err.Error()))
+ }
+ }
+
+ done("")
+}
+
+func createBackup(sourceDir, backupDir string) (string, error) {
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return "", err
+ }
+
+ name := "backup_" + time.Now().Format("2006-01-02_15-04-05") + ".zip"
+ f, err := os.Create(filepath.Join(backupDir, name))
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ zw := zip.NewWriter(f)
+ zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
+ return flate.NewWriter(out, flate.BestCompression)
+ })
+
+ walkErr := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ rel, err := filepath.Rel(sourceDir, path)
+ if err != nil {
+ return err
+ }
+
+ w, err := zw.Create(filepath.ToSlash(rel))
+ if err != nil {
+ return err
+ }
+
+ src, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer src.Close()
+
+ _, err = io.Copy(w, src)
+ return err
+ })
+ if walkErr != nil {
+ zw.Close()
+ return "", walkErr
+ }
+
+ if err := zw.Close(); err != nil {
+ return "", err
+ }
+ return name, nil
+}
+
+func runShellCommand(command string, emit func(string)) error {
+ var cmd *exec.Cmd
+ if runtime.GOOS == "windows" {
+ cmd = exec.Command("cmd", "/C", command)
+ } else {
+ cmd = exec.Command("sh", "-c", command)
+ }
+
+ 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)
+ for scanner.Scan() {
+ emit(mutedStyle.Render(" │ " + scanner.Text()))
+ }
+ return cmd.Wait()
+}
+
+func movePath(src, dst string) error {
+ if err := os.Rename(src, dst); err == nil {
+ return nil
+ }
+
+ info, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+
+ if info.IsDir() {
+ if err := copyDir(src, dst); err != nil {
+ return err
+ }
+ } else if err := copyFile(src, dst, info.Mode()); err != nil {
+ return err
+ }
+ return os.RemoveAll(src)
+}
+
+func copyDir(src, dst string) error {
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ return err
+ }
+
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ s := filepath.Join(src, e.Name())
+ d := filepath.Join(dst, e.Name())
+
+ if e.IsDir() {
+ if err := copyDir(s, d); err != nil {
+ return err
+ }
+ continue
+ }
+
+ info, err := e.Info()
+ if err != nil {
+ return err
+ }
+ if err := copyFile(s, d, info.Mode()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ return err
+}