mirror of
https://github.com/packwiz/packwiz.git
synced 2025-04-19 21:16:30 +02:00
Requires specifying datapack-path option to install datapacks (as the location varies between datapack loader mods)
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package modrinth
|
|
|
|
import (
|
|
modrinthApi "codeberg.org/jmansfield/go-modrinth/modrinth"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/packwiz/packwiz/cmdshared"
|
|
"github.com/spf13/viper"
|
|
"golang.org/x/exp/slices"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/packwiz/packwiz/core"
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/dixonwille/wmenu.v4"
|
|
)
|
|
|
|
// installCmd represents the install command
|
|
var installCmd = &cobra.Command{
|
|
Use: "add [URL|slug|search]",
|
|
Short: "Add a project from a Modrinth URL, slug/project ID or search",
|
|
Aliases: []string{"install", "get"},
|
|
Args: cobra.ArbitraryArgs,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
pack, err := core.LoadPack()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
index, err := pack.LoadIndex()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// If project/version IDs/version file name is provided in command line, use those
|
|
var projectID, versionID, versionFilename string
|
|
if projectIDFlag != "" {
|
|
projectID = projectIDFlag
|
|
}
|
|
if versionIDFlag != "" {
|
|
versionID = versionIDFlag
|
|
}
|
|
if versionFilenameFlag != "" {
|
|
versionFilename = versionFilenameFlag
|
|
}
|
|
|
|
if (len(args) == 0 || len(args[0]) == 0) && projectID == "" {
|
|
fmt.Println("You must specify a project; with the ID flags, or by passing a URL, slug or search term directly.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Try interpreting the argument as a slug/project ID, or project/version/CDN URL
|
|
var version string
|
|
parsedSlug, err := parseSlugOrUrl(args[0], &projectID, &version, &versionID, &versionFilename)
|
|
if err != nil {
|
|
fmt.Printf("Failed to parse URL: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if version != "" && versionID == "" {
|
|
// TODO: resolve version (could be an ID, could be a version number) into ID
|
|
versionID = version
|
|
}
|
|
|
|
// Got version ID; install using this ID
|
|
if versionID != "" {
|
|
err = installVersionById(versionID, versionFilename, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Look up project ID
|
|
if projectID != "" {
|
|
// Modrinth transparently handles slugs/project IDs in their API; we don't have to detect which one it is.
|
|
var project *modrinthApi.Project
|
|
project, err = mrDefaultClient.Projects.Get(projectID)
|
|
if err == nil {
|
|
// We found a project with that id/slug
|
|
err = installProject(project, versionFilename, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Arguments weren't a valid slug/project ID, try to search for it instead (if it was not parsed as a URL)
|
|
if projectID == "" || parsedSlug {
|
|
err = installViaSearch(strings.Join(args, " "), versionFilename, !parsedSlug, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
func installVersionById(versionId string, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
version, err := mrDefaultClient.Versions.Get(versionId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch version %s: %v", versionId, err)
|
|
}
|
|
|
|
project, err := mrDefaultClient.Projects.Get(*version.ProjectID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch project %s: %v", *version.ProjectID, err)
|
|
}
|
|
|
|
return installVersion(project, version, versionFilename, pack, index)
|
|
}
|
|
|
|
func installViaSearch(query string, versionFilename string, autoAcceptFirst bool, pack core.Pack, index *core.Index) error {
|
|
mcVersion, err := pack.GetMCVersion()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Searching Modrinth...")
|
|
|
|
results, err := getProjectIdsViaSearch(query, append([]string{mcVersion}, viper.GetStringSlice("acceptable-game-versions")...))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return errors.New("no projects found")
|
|
}
|
|
|
|
if viper.GetBool("non-interactive") || (len(results) == 1 && autoAcceptFirst) {
|
|
// Install the first project found
|
|
project, err := mrDefaultClient.Projects.Get(*results[0].ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return installProject(project, versionFilename, pack, index)
|
|
}
|
|
|
|
// Create menu for the user to choose the correct project
|
|
menu := wmenu.NewMenu("Choose a number:")
|
|
menu.Option("Cancel", nil, false, nil)
|
|
for i, v := range results {
|
|
// Should be non-nil (Title is a required field)
|
|
menu.Option(*v.Title, v, i == 0, nil)
|
|
}
|
|
|
|
menu.Action(func(menuRes []wmenu.Opt) error {
|
|
if len(menuRes) != 1 || menuRes[0].Value == nil {
|
|
return errors.New("project selection cancelled")
|
|
}
|
|
|
|
// Get the selected project
|
|
selectedProject, ok := menuRes[0].Value.(*modrinthApi.SearchResult)
|
|
if !ok {
|
|
return errors.New("error converting interface from wmenu")
|
|
}
|
|
|
|
// Install the selected project
|
|
project, err := mrDefaultClient.Projects.Get(*selectedProject.ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return installProject(project, versionFilename, pack, index)
|
|
})
|
|
|
|
return menu.Run()
|
|
}
|
|
|
|
func installProject(project *modrinthApi.Project, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
latestVersion, err := getLatestVersion(*project.ID, pack)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest version: %v", err)
|
|
}
|
|
if latestVersion.ID == nil {
|
|
return errors.New("mod not available for the configured Minecraft version(s) (use the acceptable-game-versions option to accept more) or loader")
|
|
}
|
|
|
|
return installVersion(project, latestVersion, versionFilename, pack, index)
|
|
}
|
|
|
|
const maxCycles = 20
|
|
|
|
type depMetadataStore struct {
|
|
projectInfo *modrinthApi.Project
|
|
versionInfo *modrinthApi.Version
|
|
fileInfo *modrinthApi.File
|
|
}
|
|
|
|
func installVersion(project *modrinthApi.Project, version *modrinthApi.Version, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
if len(version.Files) == 0 {
|
|
return errors.New("version doesn't have any files attached")
|
|
}
|
|
|
|
if len(version.Dependencies) > 0 {
|
|
// TODO: could get installed version IDs, and compare to install the newest - i.e. preferring pinned versions over getting absolute latest?
|
|
installedProjects := getInstalledProjectIDs(index)
|
|
|
|
var depMetadata []depMetadataStore
|
|
var depProjectIDPendingQueue []string
|
|
var depVersionIDPendingQueue []string
|
|
|
|
for _, dep := range version.Dependencies {
|
|
// TODO: recommend optional dependencies?
|
|
if dep.DependencyType != nil && *dep.DependencyType == "required" {
|
|
if dep.ProjectID != nil {
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *dep.ProjectID)
|
|
}
|
|
if dep.VersionID != nil {
|
|
depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 {
|
|
fmt.Println("Finding dependencies...")
|
|
|
|
cycles := 0
|
|
for len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 && cycles < maxCycles {
|
|
// Look up version IDs
|
|
if len(depVersionIDPendingQueue) > 0 {
|
|
depVersions, err := mrDefaultClient.Versions.GetMultiple(depVersionIDPendingQueue)
|
|
if err == nil {
|
|
for _, v := range depVersions {
|
|
// Add project ID to queue
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *v.ProjectID)
|
|
}
|
|
} else {
|
|
fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
|
|
}
|
|
depVersionIDPendingQueue = depVersionIDPendingQueue[:0]
|
|
}
|
|
|
|
// Remove installed project IDs from dep queue
|
|
i := 0
|
|
for _, id := range depProjectIDPendingQueue {
|
|
contains := slices.Contains(installedProjects, id)
|
|
for _, dep := range depMetadata {
|
|
if *dep.projectInfo.ID == id {
|
|
contains = true
|
|
break
|
|
}
|
|
}
|
|
if !contains {
|
|
depProjectIDPendingQueue[i] = id
|
|
i++
|
|
}
|
|
}
|
|
depProjectIDPendingQueue = depProjectIDPendingQueue[:i]
|
|
|
|
if len(depProjectIDPendingQueue) == 0 {
|
|
break
|
|
}
|
|
depProjects, err := mrDefaultClient.Projects.GetMultiple(depProjectIDPendingQueue)
|
|
if err != nil {
|
|
fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
|
|
}
|
|
depProjectIDPendingQueue = depProjectIDPendingQueue[:0]
|
|
|
|
for _, project := range depProjects {
|
|
if project.ID == nil {
|
|
return errors.New("failed to get dependency data: invalid response")
|
|
}
|
|
// Get latest version - could reuse version lookup data but it's not as easy (particularly since the version won't necessarily be the latest)
|
|
latestVersion, err := getLatestVersion(*project.ID, pack)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest version of dependency %v: %v", *project.ID, err)
|
|
}
|
|
|
|
for _, dep := range version.Dependencies {
|
|
// TODO: recommend optional dependencies?
|
|
if dep.DependencyType != nil && *dep.DependencyType == "required" {
|
|
if dep.ProjectID != nil {
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *dep.ProjectID)
|
|
}
|
|
if dep.VersionID != nil {
|
|
depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
var file = latestVersion.Files[0]
|
|
// Prefer the primary file
|
|
for _, v := range latestVersion.Files {
|
|
if *v.Primary {
|
|
file = v
|
|
}
|
|
}
|
|
|
|
depMetadata = append(depMetadata, depMetadataStore{
|
|
projectInfo: project,
|
|
versionInfo: latestVersion,
|
|
fileInfo: file,
|
|
})
|
|
}
|
|
|
|
cycles++
|
|
}
|
|
if cycles >= maxCycles {
|
|
return errors.New("dependencies recurse too deeply, try increasing maxCycles")
|
|
}
|
|
|
|
if len(depMetadata) > 0 {
|
|
fmt.Println("Dependencies found:")
|
|
for _, v := range depMetadata {
|
|
fmt.Println(*v.projectInfo.Title)
|
|
}
|
|
|
|
if cmdshared.PromptYesNo("Would you like to add them? [Y/n]: ") {
|
|
for _, v := range depMetadata {
|
|
err := createFileMeta(v.projectInfo, v.versionInfo, v.fileInfo, pack, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Dependency \"%s\" successfully added! (%s)\n", *v.projectInfo.Title, *v.fileInfo.Filename)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("All dependencies are already added!")
|
|
}
|
|
}
|
|
}
|
|
|
|
var file = version.Files[0]
|
|
// Prefer the primary file
|
|
for _, v := range version.Files {
|
|
if (*v.Primary) || (versionFilename != "" && versionFilename == *v.Filename) {
|
|
file = v
|
|
}
|
|
}
|
|
// TODO: handle optional/required resource pack files
|
|
|
|
// Create the metadata file
|
|
err := createFileMeta(project, version, file, pack, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = index.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pack.UpdateIndexHash()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pack.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Project \"%s\" successfully added! (%s)\n", *project.Title, *file.Filename)
|
|
return nil
|
|
}
|
|
|
|
func createFileMeta(project *modrinthApi.Project, version *modrinthApi.Version, file *modrinthApi.File, pack core.Pack, index *core.Index) error {
|
|
updateMap := make(map[string]map[string]interface{})
|
|
|
|
var err error
|
|
updateMap["modrinth"], err = mrUpdateData{
|
|
ProjectID: *project.ID,
|
|
InstalledVersion: *version.ID,
|
|
}.ToMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
side := getSide(project)
|
|
if side == "" {
|
|
return errors.New("version doesn't have a side that's supported. Server: " + *project.ServerSide + " Client: " + *project.ClientSide)
|
|
}
|
|
|
|
algorithm, hash := getBestHash(file)
|
|
if algorithm == "" {
|
|
return errors.New("file doesn't have a hash")
|
|
}
|
|
|
|
modMeta := core.Mod{
|
|
Name: *project.Title,
|
|
FileName: *file.Filename,
|
|
Side: side,
|
|
Download: core.ModDownload{
|
|
URL: *file.URL,
|
|
HashFormat: algorithm,
|
|
Hash: hash,
|
|
},
|
|
Update: updateMap,
|
|
}
|
|
var path string
|
|
folder := viper.GetString("meta-folder")
|
|
if folder == "" {
|
|
folder, err = getProjectTypeFolder(*project.ProjectType, version.Loaders, pack.GetLoaders())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if project.Slug != nil {
|
|
path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, *project.Slug+core.MetaExtension))
|
|
} else {
|
|
path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, core.SlugifyName(*project.Title)+core.MetaExtension))
|
|
}
|
|
|
|
// If the file already exists, this will overwrite it!!!
|
|
// TODO: Should this be improved?
|
|
// Current strategy is to go ahead and do stuff without asking, with the assumption that you are using
|
|
// VCS anyway.
|
|
|
|
format, hash, err := modMeta.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return index.RefreshFileWithHash(path, format, hash, true)
|
|
}
|
|
|
|
var projectIDFlag string
|
|
var versionIDFlag string
|
|
var versionFilenameFlag string
|
|
|
|
func init() {
|
|
modrinthCmd.AddCommand(installCmd)
|
|
|
|
installCmd.Flags().StringVar(&projectIDFlag, "project-id", "", "The Modrinth project ID to use")
|
|
installCmd.Flags().StringVar(&versionIDFlag, "version-id", "", "The Modrinth version ID to use")
|
|
installCmd.Flags().StringVar(&versionFilenameFlag, "version-filename", "", "The Modrinth version filename to use")
|
|
}
|