From 579532ecd715dbcdd4803b5910311f30d39bda34 Mon Sep 17 00:00:00 2001 From: Sean Latimer Date: Wed, 10 Mar 2021 13:36:34 +0000 Subject: [PATCH] feat: initial feature set --- .bra.toml | 24 +++++ .gitignore | 38 ++++++++ go.mod | 12 +++ go.sum | 30 +++++++ main.go | 88 +++++++++++++++++++ util.go | 76 ++++++++++++++++ version/version.go | 4 + zip.go | 214 +++++++++++++++++++++++++++++++++++++++++++++ zipAddon.go | 47 ++++++++++ zipManifest.go | 46 ++++++++++ 10 files changed, 579 insertions(+) create mode 100644 .bra.toml create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 util.go create mode 100644 version/version.go create mode 100644 zip.go create mode 100644 zipAddon.go create mode 100644 zipManifest.go diff --git a/.bra.toml b/.bra.toml new file mode 100644 index 0000000..6b2cf7f --- /dev/null +++ b/.bra.toml @@ -0,0 +1,24 @@ +[run] +build_delay = 1500 # Minimal interval to Trigger build event +cmds = [ + # Commands to run + ["go", "install", "-race"], +] +env_files = [] # Load env vars from files +follow_symlinks = false # Enable/disable following symbolic links of sub directories +graceful_kill = false # Wait for exit and before directly kill +ignore = [".git", "node_modules"] # Directories to exclude from watching +ignore_files = [] # Regexps for ignoring specific notifies +init_cmds = [ + # Commands run in start + ["env", "CGO_ENABLED=0"], + ["go", "install", "-race"], +] +interrupt_timout = 15 # Time to wait until force kill +watch_all = true # Watch all sub-directories +watch_dirs = [] # Directories to watch +watch_exts = [".go"] # Extensions to watch + +[sync] +listen_addr = ":5050" +remote_addr = ":5050" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2012242 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,vscode +# Edit at https://www.toptal.com/developers/gitignore?templates=go,vscode + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +dist + +test + +*.log + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +### Go Patch ### +/vendor/ +/Godeps/ + +### vscode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/go,vscode diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0233b27 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/seanlatimer/cursepack + +go 1.16 + +require ( + github.com/klauspost/compress v1.11.12 + github.com/pkg/errors v0.9.1 + github.com/spf13/jwalterweatherman v1.1.0 + github.com/stretchr/testify v1.4.0 // indirect + github.com/urfave/cli/v2 v2.3.0 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ee946d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/compress v1.11.12 h1:famVnQVu7QwryBN4jNseQdUKES71ZAOnB6UQQJPZvqk= +github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3694015 --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "errors" + "os" + "strings" + + "github.com/seanlatimer/cursepack/version" + jww "github.com/spf13/jwalterweatherman" + "github.com/urfave/cli/v2" +) + +const TEMPDIR_NAME = "cursepack" + +func main() { + app := &cli.App{ + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Value: false, + Usage: "Enables verbose logging", + EnvVars: []string{"CP_VERBOSE"}, + }, + &cli.BoolFlag{ + Name: "server", + Aliases: []string{"s"}, + Value: false, + Usage: "Enables installing for a server, will download and install Forge automatically", + EnvVars: []string{"CP_SERVER"}, + }, + &cli.StringFlag{ + Name: "dir", + Aliases: []string{"d"}, + Value: "", + Usage: "Directory to install the pack in", + EnvVars: []string{"CP_DIR"}, + }, + }, + Name: "cursepack", + Action: run, + Before: before, + Version: version.VERSION, + } + + err := app.Run(os.Args) + if err != nil { + jww.FATAL.Fatal(err) + } +} + +func before(ctx *cli.Context) error { + var err error + jww.SetStdoutThreshold(jww.LevelInfo) + if ctx.Bool("verbose") { + jww.SetStdoutThreshold(jww.LevelDebug) + } + if ctx.String("dir") == "" { + wd := "" + wd, err = os.Getwd() + ctx.Set("dir", wd) + } + return err +} + +func run(ctx *cli.Context) error { + pack := ctx.Args().Get(0) + if os.Getenv("CP_PACK") != "" { + pack = os.Getenv("CP_PACK") + } + if pack == "" { + return errors.New("Must provide a pack") + } + if strings.HasSuffix(pack, ".zip") { + return handleZipPack(PackInstallOptions{ + Pack: pack, + Path: ctx.String("dir"), + Server: ctx.Bool("server"), + }) + } + // IMPLEMENT + return errors.New("Pack IDs are not currently supported") +} + +type PackInstallOptions struct { + Pack string + Server bool + Path string +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..27f4940 --- /dev/null +++ b/util.go @@ -0,0 +1,76 @@ +package main + +import ( + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + + jww "github.com/spf13/jwalterweatherman" +) + +const FORGE_DL_URL = "https://files.minecraftforge.net/maven/net/minecraftforge/forge/" + +// fileExists checks if a file exists and is not a directory before we +// try using it to prevent further errors. +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func installForgeServer(forgeVersion string, path string) error { + jww.INFO.Println("Downloading Forge Server") + installerJar := "forge-" + forgeVersion + "-installer.jar" + installerJarPath := filepath.Join(path, "installer.jar") + if fileExists(installerJarPath) { + err := os.Remove(installerJarPath) + if err != nil { + return err + } + } + + forgeFile, err := os.Create(installerJarPath) + if err != nil { + return err + } + defer os.Remove(forgeFile.Name()) + defer forgeFile.Close() + + forgeResp, err := http.Get(FORGE_DL_URL + forgeVersion + "/" + installerJar) + if err != nil { + return err + } + defer forgeResp.Body.Close() + + _, err = io.Copy(forgeFile, forgeResp.Body) + if err != nil { + return err + } + + javaPath, err := exec.LookPath("java") + if err != nil { + return err + } + + cmdInstall := &exec.Cmd{ + Path: javaPath, + Args: []string{javaPath, "-jar", installerJarPath, "--installServer", path}, + Stdout: jww.DEBUG.Writer(), + Stderr: jww.ERROR.Writer(), + } + + jww.INFO.Println("Installing Forge Server") + err = cmdInstall.Run() + if err != nil { + return err + } + + src := filepath.Join(path, "forge-"+forgeVersion+".jar") + dest := filepath.Join(path, "server.jar") + + return os.Rename(src, dest) +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..7ec25e9 --- /dev/null +++ b/version/version.go @@ -0,0 +1,4 @@ +package version + +// VERSION current cli version +const VERSION = "0.0.0" diff --git a/zip.go b/zip.go new file mode 100644 index 0000000..482a322 --- /dev/null +++ b/zip.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "github.com/klauspost/compress/zip" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "golang.org/x/sync/errgroup" +) + +const FORGESVC_URL = "https://addons-ecs.forgesvc.net/api/v2" + +func handleZipPack(opts PackInstallOptions) error { + jww.INFO.Println("Installing ZIP pack") + tempPath := filepath.Join(os.TempDir(), TEMPDIR_NAME) + os.MkdirAll(tempPath, 0700) + + packPath, err := filepath.Abs(opts.Path) + if err != nil { + errors.Wrapf(err, "Failed to resolve path: %s", packPath) + } + zipPath := filepath.Join(packPath, opts.Pack) + + if !fileExists(zipPath) { + return fmt.Errorf("Provided zip does not exist at %s", zipPath) + } + + bytes, err := extractZipManifest(zipPath) + if err != nil { + return err + } + manifest, err := UnmarshalZipManifest(bytes) + if err != nil { + return err + } + + // TODO: Possibly change this later to not blow away the whole mods folder + modsDest := filepath.Join(packPath, "mods") + err = os.RemoveAll(modsDest) + if err != nil { + return err + } + + err = downloadPackMods(manifest, modsDest) + if err != nil { + return err + } + + err = extractZipOverrides(zipPath, manifest.Overrides, packPath) + if err != nil { + return err + } + + if opts.Server { + mcVersion := manifest.Minecraft.Version + modLoader := manifest.Minecraft.ModLoaders[0].ID + forgeVersion := mcVersion + "-" + modLoader[6:] + err := installForgeServer(forgeVersion, packPath) + if err != nil { + return err + } + } + + return err +} + +// https://addons-ecs.forgesvc.net/api/v2/addon/231275/file/3222705 +func downloadPackMods(manifest ZipManifest, dest string) error { + jww.INFO.Println("Downloading pack files") + err := os.MkdirAll(dest, 0700) + if err != nil { + return err + } + + var g errgroup.Group + sem := make(chan struct{}, 5) + for _, file := range manifest.Files { + file := file // create locals for closure below + sem <- struct{}{} + g.Go(func() error { + defer func() { + <-sem + }() + zipAddon, err := downloadZipAddon(file.ProjectID, file.FileID) + if err != nil { + return err + } + + jww.INFO.Printf("Downloading %s", zipAddon.FileName) + modPath := filepath.Join(dest, path.Base(zipAddon.DownloadURL)) + modFile, err := os.Create(modPath) + if err != nil { + return err + } + defer modFile.Close() + + modResp, err := http.Get(zipAddon.DownloadURL) + if err != nil { + return err + } + defer modResp.Body.Close() + + _, err = io.Copy(modFile, modResp.Body) + if err != nil { + return err + } + + return nil + }) + } + return g.Wait() +} + +func downloadZipAddon(projectID int64, fileID int64) (ZipAddon, error) { + resp, err := http.Get(fmt.Sprintf("%s/addon/%d/file/%d", FORGESVC_URL, projectID, fileID)) + var zipAddon ZipAddon + if err != nil { + return zipAddon, err + } + defer resp.Body.Close() + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return zipAddon, err + } + + zipAddon, err = UnmarshalZipAddon(data) + if err != nil { + return zipAddon, err + } + + return zipAddon, nil +} + +func extractZipManifest(zipPath string) ([]byte, error) { + jww.INFO.Println("Extracting Manifest") + zReader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer zReader.Close() + for _, src := range zReader.File { + if src.Name == "manifest.json" { + r, err := src.Open() + if err != nil { + return nil, err + } + defer r.Close() + return ioutil.ReadAll(r) + } + } + return nil, errors.Errorf("Failed to find manifest in pack") +} + +func extractZipOverrides(zipPath string, overrides string, dest string) error { + jww.INFO.Println("Extracting Overrides") + zReader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer zReader.Close() + + var g errgroup.Group + sem := make(chan struct{}, 5) + for _, src := range zReader.File { + src := src // create local for closure + if filepath.HasPrefix(src.Name, overrides) { + sem <- struct{}{} + g.Go(func() error { + defer func() { + <-sem + }() + path := filepath.Join(dest, filepath.Clean(src.Name[len(overrides):])) + dir := filepath.Dir(path) + err := os.MkdirAll(dir, 0700) + if err != nil { + return err + } + + if !strings.HasSuffix(src.Name, "/") { + jww.DEBUG.Printf("%s -> %s", src.Name, path) + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + r, err := src.Open() + if err != nil { + return err + } + defer r.Close() + + _, err = io.Copy(file, r) + if err != nil { + return err + } + + } + return nil + }) + } + } + + return g.Wait() +} diff --git a/zipAddon.go b/zipAddon.go new file mode 100644 index 0000000..7710552 --- /dev/null +++ b/zipAddon.go @@ -0,0 +1,47 @@ +// This file was generated from JSON Schema using quicktype, do not modify it directly. +// To parse and unparse this JSON data, add this code to your project and do: +// +// zipAddon, err := UnmarshalZipAddon(bytes) +// bytes, err = zipAddon.Marshal() + +package main + +import "encoding/json" + +func UnmarshalZipAddon(data []byte) (ZipAddon, error) { + var r ZipAddon + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ZipAddon) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +type ZipAddon struct { + ID int64 `json:"id"` + DisplayName string `json:"displayName"` + FileName string `json:"fileName"` + FileDate string `json:"fileDate"` + FileLength int64 `json:"fileLength"` + ReleaseType int64 `json:"releaseType"` + FileStatus int64 `json:"fileStatus"` + DownloadURL string `json:"downloadUrl"` + IsAlternate bool `json:"isAlternate"` + AlternateFileID int64 `json:"alternateFileId"` + Dependencies []interface{} `json:"dependencies"` + IsAvailable bool `json:"isAvailable"` + Modules []Module `json:"modules"` + PackageFingerprint int64 `json:"packageFingerprint"` + GameVersion []string `json:"gameVersion"` + InstallMetadata interface{} `json:"installMetadata"` + ServerPackFileID interface{} `json:"serverPackFileId"` + HasInstallScript bool `json:"hasInstallScript"` + GameVersionDateReleased string `json:"gameVersionDateReleased"` + GameVersionFlavor interface{} `json:"gameVersionFlavor"` +} + +type Module struct { + Foldername string `json:"foldername"` + Fingerprint int64 `json:"fingerprint"` +} diff --git a/zipManifest.go b/zipManifest.go new file mode 100644 index 0000000..f02da10 --- /dev/null +++ b/zipManifest.go @@ -0,0 +1,46 @@ +// This file was generated from JSON Schema using quicktype, do not modify it directly. +// To parse and unparse this JSON data, add this code to your project and do: +// +// zipManifest, err := UnmarshalZipManifest(bytes) +// bytes, err = zipManifest.Marshal() + +package main + +import "encoding/json" + +func UnmarshalZipManifest(data []byte) (ZipManifest, error) { + var r ZipManifest + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ZipManifest) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +type ZipManifest struct { + Minecraft Minecraft `json:"minecraft"` + ManifestType string `json:"manifestType"` + Overrides string `json:"overrides"` + ManifestVersion int64 `json:"manifestVersion"` + Version string `json:"version"` + Author string `json:"author"` + Name string `json:"name"` + Files []File `json:"files"` +} + +type File struct { + ProjectID int64 `json:"projectID"` + FileID int64 `json:"fileID"` + Required bool `json:"required"` +} + +type Minecraft struct { + Version string `json:"version"` + ModLoaders []ModLoader `json:"modLoaders"` +} + +type ModLoader struct { + ID string `json:"id"` + Primary bool `json:"primary"` +}