diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 9dc9efb..b98c532 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -62,6 +62,12 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v2 + - name: Setup steamcmd + uses: CyberAndrii/setup-steamcmd@v1 + + - name: Install Satisfactory Dedicated Server + run: steamcmd +login anonymous +force_install_dir $GITHUB_WORKSPACE/SatisfactoryDedicatedServer +app_update 1690800 validate +quit && ls -lR + - name: Download GQL schema run: "npx graphqurl https://api.ficsit.app/v2/query --introspect -H 'content-type: application/json' > schema.graphql" @@ -70,4 +76,6 @@ jobs: - name: Test run: go test ./... + env: + SF_DEDICATED_SERVER: $GITHUB_WORKSPACE/SatisfactoryDedicatedServer diff --git a/cfg/test_defaults.go b/cfg/test_defaults.go index bbf5c89..1f59f4c 100644 --- a/cfg/test_defaults.go +++ b/cfg/test_defaults.go @@ -15,5 +15,6 @@ func SetDefaults() { viper.SetDefault("profiles-file", "profiles.json") viper.SetDefault("installations-file", "installations.json") viper.SetDefault("dry-run", false) - viper.SetDefault("api", "https://api.ficsit.app/v2/query") + viper.SetDefault("api-base", "https://api.ficsit.app") + viper.SetDefault("graphql-api", "/v2/query") } diff --git a/cli/dependency_resolver.go b/cli/dependency_resolver.go index f34f698..5766084 100644 --- a/cli/dependency_resolver.go +++ b/cli/dependency_resolver.go @@ -2,14 +2,19 @@ package cli import ( "context" + "fmt" "sort" "github.com/Khan/genqlient/graphql" "github.com/Masterminds/semver/v3" "github.com/pkg/errors" "github.com/satisfactorymodding/ficsit-cli/ficsit" + "github.com/satisfactorymodding/ficsit-cli/utils" + "github.com/spf13/viper" ) +const smlDownloadTemplate = `https://github.com/satisfactorymodding/SatisfactoryModLoader/releases/download/%s/SML.zip` + type DependencyResolver struct { apiClient graphql.Client } @@ -18,25 +23,94 @@ func NewDependencyResolver(apiClient graphql.Client) DependencyResolver { return DependencyResolver{apiClient: apiClient} } -func (d DependencyResolver) ResolveModDependencies(constraints map[string]string) (map[string]ModVersion, error) { - results := make(map[string]ModVersion) +func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) { + smlVersionsDB, err := ficsit.SMLVersions(context.TODO(), d.apiClient) + if err != nil { + return nil, errors.Wrap(err, "failed fetching SMl versions") + } - toResolve := constraints + instance := &resolvingInstance{ + Resolver: d, + InputLock: lockFile, + ToResolve: utils.CopyMap(constraints), + OutputLock: make(LockFile), + SMLVersions: smlVersionsDB, + GameVersion: gameVersion, + } - for len(toResolve) > 0 { - converted := make([]ficsit.ModVersionConstraint, 0) - for id, constraint := range toResolve { - converted = append(converted, ficsit.ModVersionConstraint{ - ModIdOrReference: id, - Version: constraint, - }) + if err := instance.Step(); err != nil { + return nil, err + } + + return instance.OutputLock, nil +} + +type resolvingInstance struct { + Resolver DependencyResolver + + InputLock *LockFile + + ToResolve map[string]string + + OutputLock LockFile + + SMLVersions *ficsit.SMLVersionsResponse + GameVersion int +} + +func (r *resolvingInstance) Step() error { + if len(r.ToResolve) > 0 { + if err := r.LockStep(make(map[string]bool)); err != nil { + return err } - toResolve = make(map[string]string) + converted := make([]ficsit.ModVersionConstraint, 0) + for id, constraint := range r.ToResolve { + if id != "SML" { + converted = append(converted, ficsit.ModVersionConstraint{ + ModIdOrReference: id, + Version: constraint, + }) + } else { + smlVersionConstraint, _ := semver.NewConstraint(constraint) + if existingSML, ok := r.OutputLock[id]; ok { + if !smlVersionConstraint.Check(semver.MustParse(existingSML.Version)) { + return errors.New("failed resolving dependencies. requires different versions of " + id) + } + } - dependencies, err := ficsit.ResolveModDependencies(context.TODO(), d.apiClient, converted) + var chosenSMLVersion *semver.Version + for _, version := range r.SMLVersions.SmlVersions.Sml_versions { + if version.Satisfactory_version > r.GameVersion { + continue + } + + currentVersion := semver.MustParse(version.Version) + if smlVersionConstraint.Check(currentVersion) { + if chosenSMLVersion == nil || currentVersion.GreaterThan(chosenSMLVersion) { + chosenSMLVersion = currentVersion + } + } + } + + if chosenSMLVersion == nil { + return fmt.Errorf("could not find an SML version that matches constraint %s and game version %d", constraint, r.GameVersion) + } + + r.OutputLock[id] = LockedMod{ + Version: chosenSMLVersion.String(), + Link: fmt.Sprintf(smlDownloadTemplate, chosenSMLVersion.String()), + Dependencies: map[string]string{}, + } + } + } + + r.ToResolve = make(map[string]string) + + // TODO Cache + dependencies, err := ficsit.ResolveModDependencies(context.TODO(), r.Resolver.apiClient, converted) if err != nil { - return nil, errors.Wrap(err, "failed resolving mod dependencies") + return errors.Wrap(err, "failed resolving mod dependencies") } for _, mod := range dependencies.Mods { @@ -55,6 +129,8 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string modVersions[i] = ModVersion{ ID: version.Id, Version: version.Version, + Link: viper.GetString("api-base") + version.Link, + Hash: version.Hash, Dependencies: versionDependencies, } } @@ -66,21 +142,34 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string }) // Pick latest version + // TODO Clone and branch selectedVersion := modVersions[0] - if _, ok := results[mod.Mod_reference]; ok { - if results[mod.Mod_reference].Version != selectedVersion.Version { - return nil, errors.New("failed resolving dependencies. requires different versions of " + mod.Mod_reference) + if _, ok := r.OutputLock[mod.Mod_reference]; ok { + if r.OutputLock[mod.Mod_reference].Version != selectedVersion.Version { + return errors.New("failed resolving dependencies. requires different versions of " + mod.Mod_reference) } } - results[mod.Mod_reference] = selectedVersion + modDependencies := make(map[string]string) + for _, dependency := range selectedVersion.Dependencies { + if !dependency.Optional { + modDependencies[dependency.ModReference] = dependency.Constraint + } + } + + r.OutputLock[mod.Mod_reference] = LockedMod{ + Version: selectedVersion.Version, + Hash: selectedVersion.Hash, + Link: selectedVersion.Link, + Dependencies: modDependencies, + } for _, dependency := range selectedVersion.Dependencies { - if previousSelectedVersion, ok := results[dependency.ModReference]; ok { + if previousSelectedVersion, ok := r.OutputLock[dependency.ModReference]; ok { constraint, _ := semver.NewConstraint(dependency.Constraint) if !constraint.Check(semver.MustParse(previousSelectedVersion.Version)) { - return nil, errors.Errorf("mod %s version %s does not match constraint %s", + return errors.Errorf("mod %s version %s does not match constraint %s", dependency.ModReference, previousSelectedVersion.Version, dependency.Constraint, @@ -88,22 +177,96 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string } } - // TODO If already exists, verify which constraint is newer and use that - toResolve[dependency.ModReference] = dependency.Constraint + if resolving, ok := r.ToResolve[dependency.ModReference]; ok { + constraint, _ := semver.NewConstraint(dependency.Constraint) + resolvingConstraint, _ := semver.NewConstraint(resolving) + intersects, _ := constraint.Intersects(resolvingConstraint) + if !intersects { + return errors.Errorf("mod %s constraint %s does not intersect with %s", + dependency.ModReference, + resolving, + dependency.Constraint, + ) + } + } + + if dependency.Optional { + continue + } + + r.ToResolve[dependency.ModReference] = dependency.Constraint } } for _, constraint := range converted { - // Ignore SML - if constraint.ModIdOrReference == "SML" { - continue - } - - if _, ok := results[constraint.ModIdOrReference]; !ok { - return nil, errors.New("failed resolving dependency: " + constraint.ModIdOrReference) + if _, ok := r.OutputLock[constraint.ModIdOrReference]; !ok { + return errors.New("failed resolving dependency: " + constraint.ModIdOrReference) } } } - return results, nil + if len(r.ToResolve) > 0 { + if err := r.Step(); err != nil { + return err + } + } + + return nil +} + +func (r *resolvingInstance) LockStep(viewed map[string]bool) error { + added := false + if r.InputLock != nil { + for modReference, version := range r.ToResolve { + if _, ok := viewed[modReference]; ok { + continue + } + + viewed[modReference] = true + + if locked, ok := (*r.InputLock)[modReference]; ok { + constraint, _ := semver.NewConstraint(version) + if constraint.Check(semver.MustParse(locked.Version)) { + delete(r.ToResolve, modReference) + r.OutputLock[modReference] = locked + for k, v := range locked.Dependencies { + if alreadyResolving, ok := r.ToResolve[k]; ok { + cs1, _ := semver.NewConstraint(v) + cs2, _ := semver.NewConstraint(alreadyResolving) + intersects, _ := cs1.Intersects(cs2) + if !intersects { + return errors.Errorf("mod %s constraint %s does not intersect with %s", + k, + v, + alreadyResolving, + ) + } + continue + } + + if outVersion, ok := r.OutputLock[k]; ok { + constraint, _ := semver.NewConstraint(v) + if !constraint.Check(semver.MustParse(outVersion.Version)) { + return errors.Errorf("mod %s version %s does not match constraint %s", + k, + outVersion.Version, + v, + ) + } + continue + } + + r.ToResolve[k] = v + added = true + } + } + } + } + } + if added { + if err := r.LockStep(viewed); err != nil { + return err + } + } + return nil } diff --git a/cli/installations.go b/cli/installations.go index f22d05f..ca262f9 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -5,6 +5,9 @@ import ( "fmt" "os" "path" + "path/filepath" + + "github.com/satisfactorymodding/ficsit-cli/utils" "github.com/pkg/errors" "github.com/rs/zerolog/log" @@ -102,8 +105,14 @@ func (i *Installations) Save() error { } func (i *Installations) AddInstallation(ctx *GlobalContext, installPath string, profile string) (*Installation, error) { + absolutePath, err := filepath.Abs(installPath) + + if err != nil { + return nil, errors.Wrap(err, "could not resolve absolute path of: "+installPath) + } + installation := &Installation{ - Path: installPath, + Path: absolutePath, Profile: profile, } @@ -220,7 +229,66 @@ func (i *Installation) Install(ctx *GlobalContext) error { return errors.Wrap(err, "failed to validate installation") } - // TODO Install from lockfile + platform, err := i.GetPlatform(ctx) + if err != nil { + return err + } + + lockfilePath := path.Join(i.Path, platform.LockfilePath) + + var lockFile *LockFile + lockFileJSON, err := os.ReadFile(lockfilePath) + if err != nil { + if !os.IsNotExist(err) { + return errors.Wrap(err, "failed reading lockfile") + } + } else { + if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil { + return errors.Wrap(err, "failed parsing lockfile") + } + } + + resolver := NewDependencyResolver(ctx.APIClient) + + gameVersion, err := i.GetGameVersion(ctx) + if err != nil { + return errors.Wrap(err, "failed to detect game version") + } + + lockfile, err := ctx.Profiles.Profiles[i.Profile].Resolve(resolver, lockFile, gameVersion) + + if err != nil { + return errors.Wrap(err, "could not resolve mods") + } + + modsDirectory := path.Join(i.Path, "FactoryGame", "Mods") + if err := os.MkdirAll(modsDirectory, 0777); err != nil { + return errors.Wrap(err, "failed creating Mods directory") + } + + for modReference, version := range lockfile { + // Only install if a link is provided, otherwise assume mod is already installed + if version.Link != "" { + reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link) + if err != nil { + return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) + } + + if err := utils.ExtractMod(reader, size, path.Join(modsDirectory, modReference)); err != nil { + return errors.Wrap(err, "could not extract "+modReference) + } + } + } + + marshaledLockfile, err := json.MarshalIndent(lockfile, "", " ") + + if err != nil { + return errors.Wrap(err, "failed to serialize lockfile json") + } + + if err := os.WriteFile(lockfilePath, marshaledLockfile, 0777); err != nil { + return errors.Wrap(err, "failed writing lockfile") + } return nil } @@ -242,3 +310,63 @@ func (i *Installation) SetProfile(ctx *GlobalContext, profile string) error { return nil } + +type gameVersionFile struct { + MajorVersion int `json:"MajorVersion"` + MinorVersion int `json:"MinorVersion"` + PatchVersion int `json:"PatchVersion"` + Changelist int `json:"Changelist"` + CompatibleChangelist int `json:"CompatibleChangelist"` + IsLicenseeVersion int `json:"IsLicenseeVersion"` + IsPromotedBuild int `json:"IsPromotedBuild"` + BranchName string `json:"BranchName"` + BuildID string `json:"BuildId"` +} + +func (i *Installation) GetGameVersion(ctx *GlobalContext) (int, error) { + if err := i.Validate(ctx); err != nil { + return 0, errors.Wrap(err, "failed to validate installation") + } + + platform, err := i.GetPlatform(ctx) + if err != nil { + return 0, err + } + + fullPath := path.Join(i.Path, platform.VersionPath) + file, err := os.ReadFile(fullPath) + if err != nil { + if os.IsNotExist(err) { + return 0, errors.Wrap(err, "could not find game version file") + } + return 0, errors.Wrap(err, "failed reading version file") + } + + var versionData gameVersionFile + if err := json.Unmarshal(file, &versionData); err != nil { + return 0, errors.Wrap(err, "failed to parse version file json") + } + + return versionData.Changelist, nil +} + +func (i *Installation) GetPlatform(ctx *GlobalContext) (*Platform, error) { + if err := i.Validate(ctx); err != nil { + return nil, errors.Wrap(err, "failed to validate installation") + } + + for _, platform := range platforms { + fullPath := path.Join(i.Path, platform.VersionPath) + _, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + continue + } else { + return nil, errors.Wrap(err, "failed detecting version file") + } + } + return &platform, nil + } + + return nil, errors.New("no platform detected") +} diff --git a/cli/installations_test.go b/cli/installations_test.go index 1fa6baa..805e665 100644 --- a/cli/installations_test.go +++ b/cli/installations_test.go @@ -1,6 +1,7 @@ package cli import ( + "os" "testing" "github.com/MarvinJWendt/testza" @@ -27,11 +28,13 @@ func TestAddInstallation(t *testing.T) { testza.AssertNoError(t, profile.AddMod("AreaActions", ">=1.6.5")) testza.AssertNoError(t, profile.AddMod("ArmorModules__Modpack_All", ">=1.4.1")) - // TODO Re-enable conditionally - //installation, err := ctx.Installations.AddInstallation(ctx, "../testdata/server", profileName) - //testza.AssertNoError(t, err) - //testza.AssertNotNil(t, installation) - // - //err = installation.Install(ctx) - //testza.AssertNoError(t, err) + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.Install(ctx) + testza.AssertNoError(t, err) + } } diff --git a/cli/lockfile.go b/cli/lockfile.go index 5300156..651e113 100644 --- a/cli/lockfile.go +++ b/cli/lockfile.go @@ -4,5 +4,15 @@ type LockFile map[string]LockedMod type LockedMod struct { Version string `json:"version"` + Hash string `json:"hash"` + Link string `json:"link"` Dependencies map[string]string `json:"dependencies"` } + +func (l LockFile) Clone() LockFile { + lockFile := make(LockFile) + for k, v := range l { + lockFile[k] = v + } + return lockFile +} diff --git a/cli/platforms.go b/cli/platforms.go new file mode 100644 index 0000000..acc54fb --- /dev/null +++ b/cli/platforms.go @@ -0,0 +1,23 @@ +package cli + +import "path" + +type Platform struct { + VersionPath string + LockfilePath string +} + +var platforms = []Platform{ + { + VersionPath: path.Join("Engine", "Binaries", "Linux", "UE4Server-Linux-Shipping.version"), + LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + }, + { + VersionPath: path.Join("Engine", "Binaries", "Win64", "UE4Server-Win64-Shipping.version"), + LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + }, + { + VersionPath: path.Join("Engine", "Binaries", "Win64", "FactoryGame-Win64-Shipping.version"), + LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + }, +} diff --git a/cli/profiles.go b/cli/profiles.go index 50b5ce1..2fd763e 100644 --- a/cli/profiles.go +++ b/cli/profiles.go @@ -47,7 +47,7 @@ type Profile struct { type ProfileMod struct { Version string `json:"version"` - Enabled bool `json:"enabled"` // TODO Implement + Enabled bool `json:"enabled"` } func InitProfiles() (*Profiles, error) { @@ -244,6 +244,7 @@ func (p *Profile) AddMod(reference string, version string) error { p.Mods[reference] = ProfileMod{ Version: version, + Enabled: true, } return nil @@ -274,31 +275,18 @@ func (p *Profile) HasMod(reference string) bool { // An optional lockfile can be passed if one exists. // // Returns an error if resolution is impossible. -func (p *Profile) Resolve(resolver DependencyResolver, lockFile *LockFile) (*LockFile, error) { +func (p *Profile) Resolve(resolver DependencyResolver, lockFile *LockFile, gameVersion int) (LockFile, error) { toResolve := make(map[string]string) for modReference, mod := range p.Mods { - toResolve[modReference] = mod.Version + if mod.Enabled { + toResolve[modReference] = mod.Version + } } - dependencies, err := resolver.ResolveModDependencies(toResolve) + resultLockfile, err := resolver.ResolveModDependencies(toResolve, lockFile, gameVersion) if err != nil { return nil, errors.Wrap(err, "failed resolving profile dependencies") } - resultLockFile := LockFile(make(map[string]LockedMod)) - - for modReference, version := range dependencies { - modDependencies := make(map[string]string) - - for _, dependency := range version.Dependencies { - modDependencies[dependency.ModReference] = dependency.Constraint - } - - resultLockFile[modReference] = LockedMod{ - Version: version.Version, - Dependencies: modDependencies, - } - } - - return &resultLockFile, nil + return resultLockfile, nil } diff --git a/cli/resolving_test.go b/cli/resolving_test.go index 16b5296..39f6d28 100644 --- a/cli/resolving_test.go +++ b/cli/resolving_test.go @@ -1,40 +1,45 @@ package cli import ( + "math" "testing" "github.com/MarvinJWendt/testza" - "github.com/satisfactorymodding/ficsit-cli/ficsit" ) func TestProfileResolution(t *testing.T) { - api := ficsit.InitAPI() - resolver := NewDependencyResolver(api) + ctx, err := InitCLI() + testza.AssertNoError(t, err) + + resolver := NewDependencyResolver(ctx.APIClient) resolved, err := (&Profile{ Name: DefaultProfileName, Mods: map[string]ProfileMod{ "RefinedPower": { Version: "3.0.9", + Enabled: true, }, }, - }).Resolve(resolver, nil) + }).Resolve(resolver, nil, math.MaxInt) testza.AssertNoError(t, err) testza.AssertNotNil(t, resolved) - testza.AssertLen(t, *resolved, 3) + testza.AssertLen(t, resolved, 4) _, err = (&Profile{ Name: DefaultProfileName, Mods: map[string]ProfileMod{ "RefinedPower": { Version: "3.0.9", + Enabled: true, }, "RefinedRDLib": { Version: "1.0.6", + Enabled: true, }, }, - }).Resolve(resolver, nil) + }).Resolve(resolver, nil, math.MaxInt) testza.AssertEqual(t, "failed resolving profile dependencies: mod RefinedRDLib version 1.0.6 does not match constraint ^1.0.7", err.Error()) @@ -43,9 +48,10 @@ func TestProfileResolution(t *testing.T) { Mods: map[string]ProfileMod{ "ThisModDoesNotExist$$$": { Version: ">0.0.0", + Enabled: true, }, }, - }).Resolve(resolver, nil) + }).Resolve(resolver, nil, math.MaxInt) testza.AssertEqual(t, "failed resolving profile dependencies: failed resolving dependency: ThisModDoesNotExist$$$", err.Error()) } diff --git a/cli/types.go b/cli/types.go index 63e7870..bee4c11 100644 --- a/cli/types.go +++ b/cli/types.go @@ -3,6 +3,8 @@ package cli type ModVersion struct { ID string Version string + Link string + Hash string Dependencies []VersionDependency } diff --git a/cmd/root.go b/cmd/root.go index bee6d47..a825313 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -50,7 +50,7 @@ var rootCmd = &cobra.Command{ } if viper.GetString("log-file") != "" { - logFile, err := os.OpenFile(viper.GetString("log-file"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logFile, err := os.OpenFile(viper.GetString("log-file"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0777) if err != nil { return errors.Wrap(err, "failed to open log file") @@ -122,7 +122,8 @@ func init() { rootCmd.PersistentFlags().String("profiles-file", "profiles.json", "The profiles file") rootCmd.PersistentFlags().String("installations-file", "installations.json", "The installations file") - rootCmd.PersistentFlags().String("api", "https://api.ficsit.app/v2/query", "URL for API") + rootCmd.PersistentFlags().String("api-base", "https://api.ficsit.app", "URL for API") + rootCmd.PersistentFlags().String("graphql-api", "/v2/query", "Path for GraphQL API") _ = viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) @@ -136,5 +137,6 @@ func init() { _ = viper.BindPFlag("profiles-file", rootCmd.PersistentFlags().Lookup("profiles-file")) _ = viper.BindPFlag("installations-file", rootCmd.PersistentFlags().Lookup("installations-file")) - _ = viper.BindPFlag("api", rootCmd.PersistentFlags().Lookup("api")) + _ = viper.BindPFlag("api-base", rootCmd.PersistentFlags().Lookup("api-base")) + _ = viper.BindPFlag("graphql-api", rootCmd.PersistentFlags().Lookup("graphql-api")) } diff --git a/ficsit/queries/resolve_mod_dependencies.graphql b/ficsit/queries/resolve_mod_dependencies.graphql index 1b85848..e124a12 100644 --- a/ficsit/queries/resolve_mod_dependencies.graphql +++ b/ficsit/queries/resolve_mod_dependencies.graphql @@ -5,6 +5,8 @@ query ResolveModDependencies ($filter: [ModVersionConstraint!]!) { versions { id version + link + hash dependencies { condition mod_id diff --git a/ficsit/queries/sml_versions.graphql b/ficsit/queries/sml_versions.graphql index d220085..13264a5 100644 --- a/ficsit/queries/sml_versions.graphql +++ b/ficsit/queries/sml_versions.graphql @@ -5,10 +5,7 @@ query SMLVersions { sml_versions { id version - link satisfactory_version - stability - updated_at } } } \ No newline at end of file diff --git a/ficsit/root.go b/ficsit/root.go index ce1e85f..36613e6 100644 --- a/ficsit/root.go +++ b/ficsit/root.go @@ -8,5 +8,5 @@ import ( ) func InitAPI() graphql.Client { - return graphql.NewClient(viper.GetString("api"), http.DefaultClient) + return graphql.NewClient(viper.GetString("api-base")+viper.GetString("graphql-api"), http.DefaultClient) } diff --git a/go.mod b/go.mod index c70fa0b..064a4a8 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/spf13/viper v1.11.0 ) +replace github.com/Masterminds/semver/v3 v3.1.1 => github.com/Vilsol/semver/v3 v3.1.2-0.20220414201711-64ef71d40f9a + require ( github.com/agnivade/levenshtein v1.1.0 // indirect github.com/alecthomas/chroma v0.10.0 // indirect diff --git a/go.sum b/go.sum index c53237a..3b5ecd6 100644 --- a/go.sum +++ b/go.sum @@ -52,11 +52,11 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY= github.com/MarvinJWendt/testza v0.4.1 h1:bqidLqFVtySvyq7D+xIfFKefl+AfJtDpivXC9fx3hm4= github.com/MarvinJWendt/testza v0.4.1/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/Vilsol/semver/v3 v3.1.2-0.20220414201711-64ef71d40f9a h1:Z443bc6RS9J5qRi7KGqWpStbNYxhDWtSqK/mPQNsIO4= +github.com/Vilsol/semver/v3 v3.1.2-0.20220414201711-64ef71d40f9a/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM= github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= diff --git a/tea/root.go b/tea/root.go index 34b99ee..f116370 100644 --- a/tea/root.go +++ b/tea/root.go @@ -80,10 +80,6 @@ func (m *rootModel) GetGlobal() *cli.GlobalContext { return m.global } -func (m *rootModel) ResolveModDependencies(constraints map[string]string) (map[string]cli.ModVersion, error) { - return m.dependencyResolver.ResolveModDependencies(constraints) -} - func RunTea(global *cli.GlobalContext) error { if err := tea.NewProgram(scenes.NewMainMenu(newModel(global)), tea.WithAltScreen(), tea.WithMouseCellMotion()).Start(); err != nil { return errors.Wrap(err, "internal tea error") diff --git a/tea/scenes/select_mod_version.go b/tea/scenes/select_mod_version.go index accd087..f4e65b9 100644 --- a/tea/scenes/select_mod_version.go +++ b/tea/scenes/select_mod_version.go @@ -53,7 +53,7 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo }) if err != nil { - panic(err) // TODO + panic(err) // TODO Handle Error } if len(versions.Mod.Versions) == 0 { @@ -71,7 +71,7 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo version := allVersions[currentOffset+currentI] err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version) if err != nil { - panic(err) // TODO + panic(err) // TODO Handle Error } return currentModel.parent, nil }, diff --git a/utils/io.go b/utils/io.go new file mode 100644 index 0000000..6fc228c --- /dev/null +++ b/utils/io.go @@ -0,0 +1,144 @@ +package utils + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path" + + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +func DownloadOrCache(cacheKey string, hash string, url string) (r io.ReaderAt, size int64, err error) { + downloadCache := path.Join(viper.GetString("cache-dir"), "downloadCache") + if err := os.MkdirAll(downloadCache, 0777); err != nil { + if !os.IsExist(err) { + return nil, 0, errors.Wrap(err, "failed creating download cache") + } + } + + location := path.Join(downloadCache, cacheKey) + + stat, err := os.Stat(location) + if err == nil { + existingHash := "" + + if hash != "" { + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + existingHash, err = SHA256Data(f) + if err != nil { + return nil, 0, errors.Wrap(err, "could not compute hash for file: "+location) + } + } + + if hash == existingHash { + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + return f, stat.Size(), nil + } + + if err := os.Remove(location); err != nil { + return nil, 0, errors.Wrap(err, "failed to delete file: "+location) + } + } else { + if !os.IsNotExist(err) { + return nil, 0, errors.Wrap(err, "failed to stat file: "+location) + } + } + + out, err := os.Create(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed creating file at: "+location) + } + defer out.Close() + + resp, err := http.Get(url) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to fetch: "+url) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url) + } + + _, err = io.Copy(out, resp.Body) + if err != nil { + return nil, 0, errors.Wrap(err, "failed writing file to disk") + } + + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + return f, resp.ContentLength, nil +} + +func SHA256Data(f io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", errors.Wrap(err, "failed to compute hash") + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +func ExtractMod(f io.ReaderAt, size int64, location string) error { + if err := os.MkdirAll(location, 0777); err != nil { + if !os.IsExist(err) { + return errors.Wrap(err, "failed to create mod directory: "+location) + } + } else { + if err := os.RemoveAll(location); err != nil { + return errors.Wrap(err, "failed to remove directory: "+location) + } + + if err := os.MkdirAll(location, 0777); err != nil { + return errors.Wrap(err, "failed to create mod directory: "+location) + } + } + + reader, err := zip.NewReader(f, size) + if err != nil { + return errors.Wrap(err, "failed to read file as zip") + } + + for _, file := range reader.File { + if !file.FileInfo().IsDir() { + outFileLocation := path.Join(location, file.Name) + + if err := os.MkdirAll(path.Dir(outFileLocation), 0777); err != nil { + return errors.Wrap(err, "failed to create mod directory: "+location) + } + + outFile, err := os.OpenFile(outFileLocation, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return errors.Wrap(err, "failed to write to file: "+location) + } + + inFile, err := file.Open() + if err != nil { + return errors.Wrap(err, "failed to process mod zip") + } + + if _, err := io.Copy(outFile, inFile); err != nil { + return errors.Wrap(err, "failed to write to file: "+location) + } + } + } + + return nil +} diff --git a/utils/structures.go b/utils/structures.go new file mode 100644 index 0000000..b800aaf --- /dev/null +++ b/utils/structures.go @@ -0,0 +1,9 @@ +package utils + +func CopyMap[T comparable, M any](m map[T]M) map[T]M { + m2 := make(map[T]M, len(m)) + for k, v := range m { + m2[k] = v + } + return m2 +}