Resolution, downloads, lockfiles

This commit is contained in:
Vilsol 2022-05-02 23:07:15 +03:00
parent cc663b678a
commit 0647db1620
20 changed files with 565 additions and 81 deletions

View file

@ -62,6 +62,12 @@ jobs:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v2 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 - name: Download GQL schema
run: "npx graphqurl https://api.ficsit.app/v2/query --introspect -H 'content-type: application/json' > schema.graphql" run: "npx graphqurl https://api.ficsit.app/v2/query --introspect -H 'content-type: application/json' > schema.graphql"
@ -70,4 +76,6 @@ jobs:
- name: Test - name: Test
run: go test ./... run: go test ./...
env:
SF_DEDICATED_SERVER: $GITHUB_WORKSPACE/SatisfactoryDedicatedServer

View file

@ -15,5 +15,6 @@ func SetDefaults() {
viper.SetDefault("profiles-file", "profiles.json") viper.SetDefault("profiles-file", "profiles.json")
viper.SetDefault("installations-file", "installations.json") viper.SetDefault("installations-file", "installations.json")
viper.SetDefault("dry-run", false) 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")
} }

View file

@ -2,14 +2,19 @@ package cli
import ( import (
"context" "context"
"fmt"
"sort" "sort"
"github.com/Khan/genqlient/graphql" "github.com/Khan/genqlient/graphql"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/satisfactorymodding/ficsit-cli/ficsit" "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 { type DependencyResolver struct {
apiClient graphql.Client apiClient graphql.Client
} }
@ -18,25 +23,94 @@ func NewDependencyResolver(apiClient graphql.Client) DependencyResolver {
return DependencyResolver{apiClient: apiClient} return DependencyResolver{apiClient: apiClient}
} }
func (d DependencyResolver) ResolveModDependencies(constraints map[string]string) (map[string]ModVersion, error) { func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) {
results := make(map[string]ModVersion) 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 { if err := instance.Step(); err != nil {
converted := make([]ficsit.ModVersionConstraint, 0) return nil, err
for id, constraint := range toResolve { }
converted = append(converted, ficsit.ModVersionConstraint{
ModIdOrReference: id, return instance.OutputLock, nil
Version: constraint, }
})
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 { 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 { for _, mod := range dependencies.Mods {
@ -55,6 +129,8 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string
modVersions[i] = ModVersion{ modVersions[i] = ModVersion{
ID: version.Id, ID: version.Id,
Version: version.Version, Version: version.Version,
Link: viper.GetString("api-base") + version.Link,
Hash: version.Hash,
Dependencies: versionDependencies, Dependencies: versionDependencies,
} }
} }
@ -66,21 +142,34 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string
}) })
// Pick latest version // Pick latest version
// TODO Clone and branch
selectedVersion := modVersions[0] selectedVersion := modVersions[0]
if _, ok := results[mod.Mod_reference]; ok { if _, ok := r.OutputLock[mod.Mod_reference]; ok {
if results[mod.Mod_reference].Version != selectedVersion.Version { if r.OutputLock[mod.Mod_reference].Version != selectedVersion.Version {
return nil, errors.New("failed resolving dependencies. requires different versions of " + mod.Mod_reference) 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 { 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) constraint, _ := semver.NewConstraint(dependency.Constraint)
if !constraint.Check(semver.MustParse(previousSelectedVersion.Version)) { 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, dependency.ModReference,
previousSelectedVersion.Version, previousSelectedVersion.Version,
dependency.Constraint, 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 if resolving, ok := r.ToResolve[dependency.ModReference]; ok {
toResolve[dependency.ModReference] = dependency.Constraint 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 { for _, constraint := range converted {
// Ignore SML if _, ok := r.OutputLock[constraint.ModIdOrReference]; !ok {
if constraint.ModIdOrReference == "SML" { return errors.New("failed resolving dependency: " + constraint.ModIdOrReference)
continue
}
if _, ok := results[constraint.ModIdOrReference]; !ok {
return nil, 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
} }

View file

@ -5,6 +5,9 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"path/filepath"
"github.com/satisfactorymodding/ficsit-cli/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log" "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) { 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{ installation := &Installation{
Path: installPath, Path: absolutePath,
Profile: profile, Profile: profile,
} }
@ -220,7 +229,66 @@ func (i *Installation) Install(ctx *GlobalContext) error {
return errors.Wrap(err, "failed to validate installation") 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 return nil
} }
@ -242,3 +310,63 @@ func (i *Installation) SetProfile(ctx *GlobalContext, profile string) error {
return nil 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")
}

View file

@ -1,6 +1,7 @@
package cli package cli
import ( import (
"os"
"testing" "testing"
"github.com/MarvinJWendt/testza" "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("AreaActions", ">=1.6.5"))
testza.AssertNoError(t, profile.AddMod("ArmorModules__Modpack_All", ">=1.4.1")) testza.AssertNoError(t, profile.AddMod("ArmorModules__Modpack_All", ">=1.4.1"))
// TODO Re-enable conditionally serverLocation := os.Getenv("SF_DEDICATED_SERVER")
//installation, err := ctx.Installations.AddInstallation(ctx, "../testdata/server", profileName) if serverLocation != "" {
//testza.AssertNoError(t, err) installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName)
//testza.AssertNotNil(t, installation) testza.AssertNoError(t, err)
// testza.AssertNotNil(t, installation)
//err = installation.Install(ctx)
//testza.AssertNoError(t, err) err = installation.Install(ctx)
testza.AssertNoError(t, err)
}
} }

View file

@ -4,5 +4,15 @@ type LockFile map[string]LockedMod
type LockedMod struct { type LockedMod struct {
Version string `json:"version"` Version string `json:"version"`
Hash string `json:"hash"`
Link string `json:"link"`
Dependencies map[string]string `json:"dependencies"` Dependencies map[string]string `json:"dependencies"`
} }
func (l LockFile) Clone() LockFile {
lockFile := make(LockFile)
for k, v := range l {
lockFile[k] = v
}
return lockFile
}

23
cli/platforms.go Normal file
View file

@ -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"),
},
}

View file

@ -47,7 +47,7 @@ type Profile struct {
type ProfileMod struct { type ProfileMod struct {
Version string `json:"version"` Version string `json:"version"`
Enabled bool `json:"enabled"` // TODO Implement Enabled bool `json:"enabled"`
} }
func InitProfiles() (*Profiles, error) { func InitProfiles() (*Profiles, error) {
@ -244,6 +244,7 @@ func (p *Profile) AddMod(reference string, version string) error {
p.Mods[reference] = ProfileMod{ p.Mods[reference] = ProfileMod{
Version: version, Version: version,
Enabled: true,
} }
return nil return nil
@ -274,31 +275,18 @@ func (p *Profile) HasMod(reference string) bool {
// An optional lockfile can be passed if one exists. // An optional lockfile can be passed if one exists.
// //
// Returns an error if resolution is impossible. // 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) toResolve := make(map[string]string)
for modReference, mod := range p.Mods { 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 { if err != nil {
return nil, errors.Wrap(err, "failed resolving profile dependencies") return nil, errors.Wrap(err, "failed resolving profile dependencies")
} }
resultLockFile := LockFile(make(map[string]LockedMod)) return resultLockfile, nil
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
} }

View file

@ -1,40 +1,45 @@
package cli package cli
import ( import (
"math"
"testing" "testing"
"github.com/MarvinJWendt/testza" "github.com/MarvinJWendt/testza"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
) )
func TestProfileResolution(t *testing.T) { func TestProfileResolution(t *testing.T) {
api := ficsit.InitAPI() ctx, err := InitCLI()
resolver := NewDependencyResolver(api) testza.AssertNoError(t, err)
resolver := NewDependencyResolver(ctx.APIClient)
resolved, err := (&Profile{ resolved, err := (&Profile{
Name: DefaultProfileName, Name: DefaultProfileName,
Mods: map[string]ProfileMod{ Mods: map[string]ProfileMod{
"RefinedPower": { "RefinedPower": {
Version: "3.0.9", Version: "3.0.9",
Enabled: true,
}, },
}, },
}).Resolve(resolver, nil) }).Resolve(resolver, nil, math.MaxInt)
testza.AssertNoError(t, err) testza.AssertNoError(t, err)
testza.AssertNotNil(t, resolved) testza.AssertNotNil(t, resolved)
testza.AssertLen(t, *resolved, 3) testza.AssertLen(t, resolved, 4)
_, err = (&Profile{ _, err = (&Profile{
Name: DefaultProfileName, Name: DefaultProfileName,
Mods: map[string]ProfileMod{ Mods: map[string]ProfileMod{
"RefinedPower": { "RefinedPower": {
Version: "3.0.9", Version: "3.0.9",
Enabled: true,
}, },
"RefinedRDLib": { "RefinedRDLib": {
Version: "1.0.6", 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()) 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{ Mods: map[string]ProfileMod{
"ThisModDoesNotExist$$$": { "ThisModDoesNotExist$$$": {
Version: ">0.0.0", 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()) testza.AssertEqual(t, "failed resolving profile dependencies: failed resolving dependency: ThisModDoesNotExist$$$", err.Error())
} }

View file

@ -3,6 +3,8 @@ package cli
type ModVersion struct { type ModVersion struct {
ID string ID string
Version string Version string
Link string
Hash string
Dependencies []VersionDependency Dependencies []VersionDependency
} }

View file

@ -50,7 +50,7 @@ var rootCmd = &cobra.Command{
} }
if viper.GetString("log-file") != "" { 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 { if err != nil {
return errors.Wrap(err, "failed to open log file") 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("profiles-file", "profiles.json", "The profiles file")
rootCmd.PersistentFlags().String("installations-file", "installations.json", "The installations 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", rootCmd.PersistentFlags().Lookup("log"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = 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("profiles-file", rootCmd.PersistentFlags().Lookup("profiles-file"))
_ = viper.BindPFlag("installations-file", rootCmd.PersistentFlags().Lookup("installations-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"))
} }

View file

@ -5,6 +5,8 @@ query ResolveModDependencies ($filter: [ModVersionConstraint!]!) {
versions { versions {
id id
version version
link
hash
dependencies { dependencies {
condition condition
mod_id mod_id

View file

@ -5,10 +5,7 @@ query SMLVersions {
sml_versions { sml_versions {
id id
version version
link
satisfactory_version satisfactory_version
stability
updated_at
} }
} }
} }

View file

@ -8,5 +8,5 @@ import (
) )
func InitAPI() graphql.Client { 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)
} }

2
go.mod
View file

@ -19,6 +19,8 @@ require (
github.com/spf13/viper v1.11.0 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 ( require (
github.com/agnivade/levenshtein v1.1.0 // indirect github.com/agnivade/levenshtein v1.1.0 // indirect
github.com/alecthomas/chroma v0.10.0 // indirect github.com/alecthomas/chroma v0.10.0 // indirect

4
go.sum
View file

@ -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.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY=
github.com/MarvinJWendt/testza v0.4.1 h1:bqidLqFVtySvyq7D+xIfFKefl+AfJtDpivXC9fx3hm4= 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/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.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 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/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.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM= github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=

View file

@ -80,10 +80,6 @@ func (m *rootModel) GetGlobal() *cli.GlobalContext {
return m.global 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 { func RunTea(global *cli.GlobalContext) error {
if err := tea.NewProgram(scenes.NewMainMenu(newModel(global)), tea.WithAltScreen(), tea.WithMouseCellMotion()).Start(); err != nil { if err := tea.NewProgram(scenes.NewMainMenu(newModel(global)), tea.WithAltScreen(), tea.WithMouseCellMotion()).Start(); err != nil {
return errors.Wrap(err, "internal tea error") return errors.Wrap(err, "internal tea error")

View file

@ -53,7 +53,7 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo
}) })
if err != nil { if err != nil {
panic(err) // TODO panic(err) // TODO Handle Error
} }
if len(versions.Mod.Versions) == 0 { 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] version := allVersions[currentOffset+currentI]
err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version) err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version)
if err != nil { if err != nil {
panic(err) // TODO panic(err) // TODO Handle Error
} }
return currentModel.parent, nil return currentModel.parent, nil
}, },

144
utils/io.go Normal file
View file

@ -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
}

9
utils/structures.go Normal file
View file

@ -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
}