diff --git a/README.md b/README.md index 73a29d4..4001ff1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Configuration validation on load - Verbose logging and automatic confirmation via `--yes` - Command aliases for faster workflows +- Optional curses-based `dashboard` for interactive status viewing ## Prerequisites @@ -71,6 +72,10 @@ Or download a prebuilt binary for your platform if available. # or use alias: # .\modpilot.exe clean MyPack ``` +9. Launch the interactive dashboard: + ```pwsh + .\modpilot.exe dashboard + ``` ## Commands @@ -86,6 +91,7 @@ Or download a prebuilt binary for your platform if available. | `check-updates [pack]` | | Check Modrinth for newer versions and check for missing local files | | `update [pack]` | `update-pack`, `upd` | Check & download new/missing versions for a modpack, updating state | | `sync [pack]` | `sync-pack`, `clean` | Remove JARs from the modpack's directory that aren't listed in `state.json` | +| `dashboard` | | Interactive curses UI for viewing pack status | Global flags: `-c, --config`, `-s, --state`, `-m, --mods-dir`, `-y, --yes`, `-g, --mc-version` (override), `-l, --loader` (override), `-v, --verbose`. @@ -162,6 +168,7 @@ Example: `mods/MyPack/fabric-api-0.100.0+1.21.5.jar` - `list-mods` (`lm`) - `update` (`update-pack`, `upd`) - `sync` (`sync-pack`, `clean`) +- `dashboard` // Note: check-updates does not have an alias currently ## Contributing diff --git a/go.mod b/go.mod index e9f537b..f15b0d8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module deadfrost.dev/modpack-manager go 1.23.4 require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/rivo/tview v0.0.0-20230720074347-8d10e88583f3 + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect ) diff --git a/main.go b/main.go index 293f8f9..b3c2cef 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/rivo/tview" "github.com/spf13/cobra" ) @@ -670,6 +671,7 @@ func main() { update, checkUpdatesCmd, syncCmd, + dashboardCmd, ) if err := root.Execute(); err != nil { @@ -683,5 +685,125 @@ func ternary(condition bool, trueVal, falseVal string) string { if condition { return trueVal } + + // dashboard + dashboardCmd := &cobra.Command{ + Use: "dashboard", + Short: "Launch interactive curses dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := LoadConfig(cfgFile) + if err != nil { + return err + } + state, err := LoadState(stateFile) + if err != nil { + return err + } + + app := tview.NewApplication() + list := tview.NewList().ShowSecondaryText(false) + details := tview.NewTextView().SetDynamicColors(true) + details.SetBorder(true).SetTitle("Status") + + for name, pack := range cfg.Modpacks { + n := name + p := pack + list.AddItem(name, "", 0, func() { + lines, err := checkUpdatesSummary(n, p, state[n]) + if err != nil { + details.SetText(fmt.Sprintf("Error: %v", err)) + } else { + details.SetText(strings.Join(lines, "\n")) + } + app.Draw() + }) + } + list.SetBorder(true).SetTitle("Modpacks") + + flex := tview.NewFlex().AddItem(list, 0, 1, true).AddItem(details, 0, 2, false) + return app.SetRoot(flex, true).Run() + }, + } return falseVal } + +// checkUpdatesSummary returns lines describing update status for a pack +func checkUpdatesSummary(packName string, packCfg ModpackConfig, packState map[string]ModState) ([]string, error) { + gameVersion := packCfg.MCVersion + loader := packCfg.Loader + destDir := filepath.Join(modsDir, packName) + if packState == nil { + packState = make(map[string]ModState) + } + lines := []string{} + for _, slug := range packCfg.Mods { + modState, modInState := packState[slug] + fileExists := false + if modInState && modState.Filename != "" { + if _, err := os.Stat(filepath.Join(destDir, modState.Filename)); err == nil { + fileExists = true + } + } + ver, err := FetchLatestVersion(slug, gameVersion, loader) + if err != nil { + lines = append(lines, fmt.Sprintf("[red]✗ %s: %v[-]", slug, err)) + continue + } + if !modInState { + lines = append(lines, fmt.Sprintf("[green]+ %s: latest %s[-]", slug, ver.ID)) + } else if ver.ID != modState.VersionID { + missing := "" + if !fileExists { + missing = " (file missing!)" + } + lines = append(lines, fmt.Sprintf("[yellow]⚠ %s: %s → %s%s[-]", slug, modState.VersionID, ver.ID, missing)) + } else if !fileExists { + lines = append(lines, fmt.Sprintf("[yellow]! %s: file missing for %s[-]", slug, ver.ID)) + } else { + lines = append(lines, fmt.Sprintf("[blue]✓ %s: up to date (%s)[-]", slug, ver.ID)) + } + } + return lines, nil +} + +// updatePackNoPrompt downloads needed mods for a pack without prompting +func updatePackNoPrompt(packName string, packCfg ModpackConfig, state State) error { + gameVersion := packCfg.MCVersion + loader := packCfg.Loader + if state[packName] == nil { + state[packName] = make(map[string]ModState) + } + packState := state[packName] + destDir := filepath.Join(modsDir, packName) + for _, slug := range packCfg.Mods { + modState, modInState := packState[slug] + fileExists := false + expectedPath := "" + if modInState && modState.Filename != "" { + expectedPath = filepath.Join(destDir, modState.Filename) + if _, err := os.Stat(expectedPath); err == nil { + fileExists = true + } + } + ver, err := FetchLatestVersion(slug, gameVersion, loader) + if err != nil { + return err + } + needsDownload := !modInState || ver.ID != modState.VersionID || !fileExists + if !needsDownload { + continue + } + if fileExists && expectedPath != "" && modState.Filename != ver.Files[0].Filename { + _ = os.Remove(expectedPath) + } + if len(ver.Files) == 0 { + continue + } + outPath, err := DownloadFile(ver.Files[0].URL, destDir) + if err != nil { + return err + } + packState[slug] = ModState{VersionID: ver.ID, Filename: filepath.Base(outPath)} + } + return SaveState(stateFile, state) +}