feat: sftp (#51)
* feat: sftp test: add tests for ftp and sftp * chore: ci fixes * chore: potential race fix * fix: simplify existence checks * fix: split path differently for ftp * fix: 🤷 * chore: add debug print * chore: lint * chore: idk dude * chore: ? * chore: more logs * chore: wipe mods before tests * chore: logs * chore: wat * chore: wait? * chore: no errors * chore: gh actions are 💩 * fix: always sync after copy * chore: remove some test logs * chore: remove test progress watcher * refactor: change progress to writer * chore: logs * chore: different logs * chore: whoops * chore: moar logs * chore: even moar logs * chore: what is life * chore: why are we here * chore: we are just bags of water floating through space * chore: are you real? * chore: ? * chore: if you get a single update now I call bs * chore: ok what if we just do one? * chore: ok what if we do two? * chore: this should not work * chore: wait no, this one * chore: fml * chore: remove logs * chore: what if we just wait a little * chore: retry * chore: move error * chore: verbose log * chore: remove explicit sleep * chore: remove debug * fix: linux pathing on windows * fix: clean paths properly * fix: fuck ftp * fix: send update on vanilla * feat: parallel ftp * fix: remove potential credential leak
This commit is contained in:
parent
baacde400e
commit
2672b17f44
16 changed files with 676 additions and 197 deletions
8
.github/workflows/push.yaml
vendored
8
.github/workflows/push.yaml
vendored
|
@ -82,6 +82,10 @@ jobs:
|
|||
- name: Install Satisfactory Dedicated Server
|
||||
run: steamcmd +login anonymous +force_install_dir ${{ github.workspace }}/SatisfactoryDedicatedServer +app_update 1690800 validate +quit
|
||||
|
||||
- name: Change directory permissions
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: mkdir -p ${{ github.workspace }}/SatisfactoryDedicatedServer/FactoryGame/Mods && chmod -R 777 ${{ github.workspace }}/SatisfactoryDedicatedServer
|
||||
|
||||
- name: List directory (linux)
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: ls -lR
|
||||
|
@ -90,6 +94,10 @@ jobs:
|
|||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: tree /F
|
||||
|
||||
- name: Boot ftp and sftp
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: docker-compose -f docker-compose-test.yml up -d
|
||||
|
||||
- name: Download GQL schema
|
||||
run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql"
|
||||
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -128,4 +128,5 @@ dist/
|
|||
/.graphqlconfig
|
||||
schema.graphql
|
||||
*.log
|
||||
.direnv
|
||||
.direnv
|
||||
/SatisfactoryDedicatedServer
|
|
@ -1,6 +1,8 @@
|
|||
package cfg
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
|
@ -18,4 +20,8 @@ func SetDefaults() {
|
|||
viper.SetDefault("api-base", "https://api.ficsit.dev")
|
||||
viper.SetDefault("graphql-api", "/v2/query")
|
||||
viper.SetDefault("concurrent-downloads", 5)
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
}
|
||||
|
|
36
cli/cache/download.go
vendored
36
cli/cache/download.go
vendored
|
@ -4,11 +4,14 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
|
@ -84,7 +87,11 @@ func DownloadOrCache(cacheKey string, hash string, url string, updates chan<- ut
|
|||
outer:
|
||||
for {
|
||||
select {
|
||||
case update := <-upstreamUpdates:
|
||||
case update, ok := <-upstreamUpdates:
|
||||
if !ok {
|
||||
break outer
|
||||
}
|
||||
|
||||
for _, u := range group.updates {
|
||||
u <- update
|
||||
}
|
||||
|
@ -94,11 +101,29 @@ func DownloadOrCache(cacheKey string, hash string, url string, updates chan<- ut
|
|||
}
|
||||
}()
|
||||
|
||||
size, err := downloadInternal(cacheKey, location, hash, url, upstreamUpdates, downloadSemaphore)
|
||||
var size int64
|
||||
|
||||
err := retry.Do(func() error {
|
||||
var err error
|
||||
size, err = downloadInternal(cacheKey, location, hash, url, upstreamUpdates, downloadSemaphore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("internal download error: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.Attempts(5),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.FixedDelay),
|
||||
retry.OnRetry(func(n uint, err error) {
|
||||
if n > 0 {
|
||||
slog.Info("retrying download", slog.Uint64("n", uint64(n)), slog.String("cacheKey", cacheKey))
|
||||
}
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
group.err = err
|
||||
close(group.wait)
|
||||
return nil, 0, err
|
||||
return nil, 0, err // nolint
|
||||
}
|
||||
|
||||
close(upstreamWaiter)
|
||||
|
@ -175,16 +200,17 @@ func downloadInternal(cacheKey string, location string, hash string, url string,
|
|||
}
|
||||
|
||||
progresser := &utils.Progresser{
|
||||
Reader: resp.Body,
|
||||
Total: resp.ContentLength,
|
||||
Updates: updates,
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, progresser)
|
||||
_, err = io.Copy(io.MultiWriter(out, progresser), resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed writing file to disk: %w", err)
|
||||
}
|
||||
|
||||
_ = out.Sync()
|
||||
|
||||
if updates != nil {
|
||||
updates <- utils.GenericProgress{Completed: resp.ContentLength, Total: resp.ContentLength}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package cli
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/Khan/genqlient/graphql"
|
||||
"github.com/spf13/viper"
|
||||
|
@ -87,6 +88,8 @@ func (g *GlobalContext) ReInit() error {
|
|||
|
||||
// Wipe will remove any trace of ficsit anywhere
|
||||
func (g *GlobalContext) Wipe() error {
|
||||
slog.Info("wiping global context")
|
||||
|
||||
// Wipe all installations
|
||||
for _, installation := range g.Installations.Installations {
|
||||
if err := installation.Wipe(); err != nil {
|
||||
|
|
244
cli/disk/ftp.go
244
cli/disk/ftp.go
|
@ -2,23 +2,28 @@ package disk
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/puddle/v2"
|
||||
"github.com/jlaffaye/ftp"
|
||||
)
|
||||
|
||||
// TODO Make configurable
|
||||
const connectionCount = 5
|
||||
|
||||
var _ Disk = (*ftpDisk)(nil)
|
||||
|
||||
type ftpDisk struct {
|
||||
client *ftp.ServerConn
|
||||
path string
|
||||
stepLock sync.Mutex
|
||||
pool *puddle.Pool[*ftp.ServerConn]
|
||||
path string
|
||||
}
|
||||
|
||||
type ftpEntry struct {
|
||||
|
@ -39,40 +44,117 @@ func newFTP(path string) (Disk, error) {
|
|||
return nil, fmt.Errorf("failed to parse ftp url: %w", err)
|
||||
}
|
||||
|
||||
c, err := ftp.Dial(u.Host, ftp.DialWithTimeout(time.Second*5))
|
||||
pool, err := puddle.NewPool(&puddle.Config[*ftp.ServerConn]{
|
||||
Constructor: func(ctx context.Context) (*ftp.ServerConn, error) {
|
||||
c, failedHidden, err := testFTP(u, ftp.DialWithTimeout(time.Second*5), ftp.DialWithForceListHidden(true))
|
||||
if failedHidden {
|
||||
c, _, err = testFTP(u, ftp.DialWithTimeout(time.Second*5))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Info("logged into ftp", slog.Bool("hidden-files", !failedHidden))
|
||||
|
||||
return c, nil
|
||||
},
|
||||
MaxSize: connectionCount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to dial host %s: %w", u.Host, err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return &ftpDisk{
|
||||
path: u.Path,
|
||||
pool: pool,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func testFTP(u *url.URL, options ...ftp.DialOption) (*ftp.ServerConn, bool, error) {
|
||||
c, err := ftp.Dial(u.Host, options...)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to dial host %s: %w", u.Host, err)
|
||||
}
|
||||
|
||||
password, _ := u.User.Password()
|
||||
if err := c.Login(u.User.Username(), password); err != nil {
|
||||
return nil, fmt.Errorf("failed to login: %w", err)
|
||||
return nil, false, fmt.Errorf("failed to login: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("logged into ftp")
|
||||
_, err = c.List("/")
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("failed listing dir: %w", err)
|
||||
}
|
||||
|
||||
return &ftpDisk{
|
||||
path: u.Path,
|
||||
client: c,
|
||||
}, nil
|
||||
return c, false, nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) Exists(path string) error {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
func (l *ftpDisk) Exists(path string) (bool, error) {
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
slog.Debug("checking if file exists", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
_, err := l.client.FileSize(path)
|
||||
return fmt.Errorf("failed to check if file exists: %w", err)
|
||||
defer res.Release()
|
||||
|
||||
slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
|
||||
split := strings.Split(clean(path)[1:], "/")
|
||||
for _, s := range split[:len(split)-1] {
|
||||
dir, err := l.readDirLock(res, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
currentDir, _ := res.Value().CurrentDir()
|
||||
|
||||
foundDir := false
|
||||
for _, entry := range dir {
|
||||
if entry.IsDir() && entry.Name() == s {
|
||||
foundDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDir {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
|
||||
if err := res.Value().ChangeDir(s); err != nil {
|
||||
return false, fmt.Errorf("failed to enter directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
dir, err := l.readDirLock(res, "")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed listing directory: %w", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, entry := range dir {
|
||||
if entry.Name() == clean(filepath.Base(path)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) Read(path string) ([]byte, error) {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Debug("reading file", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
defer res.Release()
|
||||
|
||||
f, err := l.client.Retr(path)
|
||||
slog.Debug("reading file", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
|
||||
f, err := res.Value().Retr(clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve path: %w", err)
|
||||
}
|
||||
|
@ -88,11 +170,15 @@ func (l *ftpDisk) Read(path string) ([]byte, error) {
|
|||
}
|
||||
|
||||
func (l *ftpDisk) Write(path string, data []byte) error {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("writing to file", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
if err := l.client.Stor(path, bytes.NewReader(data)); err != nil {
|
||||
defer res.Release()
|
||||
|
||||
slog.Debug("writing to file", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
if err := res.Value().Stor(clean(path), bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
|
@ -100,34 +186,40 @@ func (l *ftpDisk) Write(path string, data []byte) error {
|
|||
}
|
||||
|
||||
func (l *ftpDisk) Remove(path string) error {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("deleting path", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
if err := l.client.Delete(path); err != nil {
|
||||
return fmt.Errorf("failed to delete path: %w", err)
|
||||
defer res.Release()
|
||||
|
||||
slog.Debug("deleting path", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
if err := res.Value().Delete(clean(path)); err != nil {
|
||||
if err := res.Value().RemoveDirRecur(clean(path)); err != nil {
|
||||
return fmt.Errorf("failed to delete path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) MkDir(path string) error {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
|
||||
slog.Debug("going to root directory", slog.String("schema", "ftp"))
|
||||
err := l.client.ChangeDir("/")
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to change directory: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
split := strings.Split(path[1:], "/")
|
||||
defer res.Release()
|
||||
|
||||
split := strings.Split(clean(path)[1:], "/")
|
||||
for _, s := range split {
|
||||
dir, err := l.ReadDirLock("", false)
|
||||
dir, err := l.readDirLock(res, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentDir, _ := res.Value().CurrentDir()
|
||||
|
||||
foundDir := false
|
||||
for _, entry := range dir {
|
||||
if entry.IsDir() && entry.Name() == s {
|
||||
|
@ -137,14 +229,14 @@ func (l *ftpDisk) MkDir(path string) error {
|
|||
}
|
||||
|
||||
if !foundDir {
|
||||
slog.Debug("making directory", slog.String("dir", s), slog.String("schema", "ftp"))
|
||||
if err := l.client.MakeDir(s); err != nil {
|
||||
slog.Debug("making directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
|
||||
if err := res.Value().MakeDir(s); err != nil {
|
||||
return fmt.Errorf("failed to make directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("entering directory", slog.String("dir", s), slog.String("schema", "ftp"))
|
||||
if err := l.client.ChangeDir(s); err != nil {
|
||||
slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
|
||||
if err := res.Value().ChangeDir(s); err != nil {
|
||||
return fmt.Errorf("failed to enter directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
@ -153,18 +245,20 @@ func (l *ftpDisk) MkDir(path string) error {
|
|||
}
|
||||
|
||||
func (l *ftpDisk) ReadDir(path string) ([]Entry, error) {
|
||||
return l.ReadDirLock(path, true)
|
||||
}
|
||||
|
||||
func (l *ftpDisk) ReadDirLock(path string, lock bool) ([]Entry, error) {
|
||||
if lock {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Debug("reading directory", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
defer res.Release()
|
||||
|
||||
dir, err := l.client.List(path)
|
||||
return l.readDirLock(res, path)
|
||||
}
|
||||
|
||||
func (l *ftpDisk) readDirLock(res *puddle.Resource[*ftp.ServerConn], path string) ([]Entry, error) {
|
||||
slog.Debug("reading directory", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
|
||||
dir, err := res.Value().List(clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list files in directory: %w", err)
|
||||
}
|
||||
|
@ -179,29 +273,49 @@ func (l *ftpDisk) ReadDirLock(path string, lock bool) ([]Entry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) IsNotExist(err error) bool {
|
||||
return strings.Contains(err.Error(), "Could not get file") || strings.Contains(err.Error(), "Failed to open file")
|
||||
}
|
||||
|
||||
func (l *ftpDisk) IsExist(err error) bool {
|
||||
return strings.Contains(err.Error(), "Create directory operation failed")
|
||||
}
|
||||
|
||||
func (l *ftpDisk) Open(path string, _ int) (io.WriteCloser, error) {
|
||||
res, err := l.acquire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, writer := io.Pipe()
|
||||
|
||||
slog.Debug("opening for writing", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
|
||||
go func() {
|
||||
l.stepLock.Lock()
|
||||
defer l.stepLock.Unlock()
|
||||
defer res.Release()
|
||||
|
||||
err := l.client.Stor(path, reader)
|
||||
err := res.Value().Stor(clean(path), reader)
|
||||
if err != nil {
|
||||
slog.Error("failed to store file", slog.Any("err", err))
|
||||
}
|
||||
slog.Debug("write success", slog.String("path", path), slog.String("schema", "ftp"))
|
||||
slog.Debug("write success", slog.String("path", clean(path)), slog.String("schema", "ftp"))
|
||||
}()
|
||||
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) goHome(res *puddle.Resource[*ftp.ServerConn]) error {
|
||||
slog.Debug("going to root directory", slog.String("schema", "ftp"))
|
||||
|
||||
err := res.Value().ChangeDir("/")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to change directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *ftpDisk) acquire() (*puddle.Resource[*ftp.ServerConn], error) {
|
||||
res, err := l.pool.Acquire(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed acquiring connection: %w", err)
|
||||
}
|
||||
|
||||
if err := l.goHome(res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
@ -19,9 +21,18 @@ func newLocal(path string) (Disk, error) {
|
|||
return localDisk{path: path}, nil
|
||||
}
|
||||
|
||||
func (l localDisk) Exists(path string) error {
|
||||
func (l localDisk) Exists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
return err //nolint
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed checking file existence: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (l localDisk) Read(path string) ([]byte, error) {
|
||||
|
@ -56,14 +67,6 @@ func (l localDisk) ReadDir(path string) ([]Entry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
func (l localDisk) IsNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func (l localDisk) IsExist(err error) bool {
|
||||
return os.IsExist(err)
|
||||
}
|
||||
|
||||
func (l localDisk) Open(path string, flag int) (io.WriteCloser, error) {
|
||||
return os.OpenFile(path, flag, 0777) //nolint
|
||||
}
|
||||
|
|
|
@ -5,11 +5,12 @@ import (
|
|||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Disk interface {
|
||||
// Exists checks if the provided file or directory exists
|
||||
Exists(path string) error
|
||||
Exists(path string) (bool, error)
|
||||
|
||||
// Read returns the entire file as a byte buffer
|
||||
//
|
||||
|
@ -30,12 +31,6 @@ type Disk interface {
|
|||
// Returns error if provided path is not a directory
|
||||
ReadDir(path string) ([]Entry, error)
|
||||
|
||||
// IsNotExist returns true if provided error is a not-exist type error
|
||||
IsNotExist(err error) bool
|
||||
|
||||
// IsExist returns true if provided error is a does-exist type error
|
||||
IsExist(err error) bool
|
||||
|
||||
// Open opens provided path for writing
|
||||
Open(path string, flag int) (io.WriteCloser, error)
|
||||
}
|
||||
|
@ -53,13 +48,18 @@ func FromPath(path string) (Disk, error) {
|
|||
|
||||
switch parsed.Scheme {
|
||||
case "ftp":
|
||||
slog.Info("connecting to ftp", slog.String("path", path))
|
||||
slog.Info("connecting to ftp")
|
||||
return newFTP(path)
|
||||
case "sftp":
|
||||
slog.Info("connecting to sftp", slog.String("path", path))
|
||||
slog.Info("connecting to sftp")
|
||||
return newSFTP(path)
|
||||
}
|
||||
|
||||
slog.Info("using local disk", slog.String("path", path))
|
||||
return newLocal(path)
|
||||
}
|
||||
|
||||
// clean returns a unix-style path
|
||||
func clean(path string) string {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
|
162
cli/disk/sftp.go
162
cli/disk/sftp.go
|
@ -1,51 +1,169 @@
|
|||
package disk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var _ Disk = (*sftpDisk)(nil)
|
||||
|
||||
type sftpDisk struct {
|
||||
path string
|
||||
client *sftp.Client
|
||||
path string
|
||||
}
|
||||
|
||||
type sftpEntry struct {
|
||||
os.FileInfo
|
||||
}
|
||||
|
||||
func (f sftpEntry) IsDir() bool {
|
||||
return f.FileInfo.IsDir()
|
||||
}
|
||||
|
||||
func (f sftpEntry) Name() string {
|
||||
return f.FileInfo.Name()
|
||||
}
|
||||
|
||||
func newSFTP(path string) (Disk, error) {
|
||||
return sftpDisk{path: path}, nil
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse sftp url: %w", err)
|
||||
}
|
||||
|
||||
password, ok := u.User.Password()
|
||||
var auth []ssh.AuthMethod
|
||||
if ok {
|
||||
auth = append(auth, ssh.Password(password))
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", u.Host, &ssh.ClientConfig{
|
||||
User: u.User.Username(),
|
||||
Auth: auth,
|
||||
|
||||
// TODO Somehow use systems hosts file
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to ssh server: %w", err)
|
||||
}
|
||||
|
||||
client, err := sftp.NewClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sftp client: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("logged into sftp")
|
||||
|
||||
return sftpDisk{
|
||||
path: path,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) Exists(path string) error { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) Exists(path string) (bool, error) {
|
||||
slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
s, err := l.client.Stat(clean(path))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to check if file exists: %w", err)
|
||||
}
|
||||
|
||||
return s != nil, nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) Read(path string) ([]byte, error) { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) Read(path string) ([]byte, error) {
|
||||
slog.Debug("reading file", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
f, err := l.client.Open(clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve path: %w", err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) Write(path string, data []byte) error { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) Write(path string, data []byte) error {
|
||||
slog.Debug("writing to file", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
file, err := l.client.Create(clean(path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
if _, err = io.Copy(file, bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) Remove(path string) error { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) Remove(path string) error {
|
||||
slog.Debug("deleting path", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
if err := l.client.Remove(clean(path)); err != nil {
|
||||
if err := l.client.RemoveAll(clean(path)); err != nil {
|
||||
return fmt.Errorf("failed to delete path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) MkDir(path string) error { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) MkDir(path string) error {
|
||||
slog.Debug("making directory", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
if err := l.client.MkdirAll(clean(path)); err != nil {
|
||||
return fmt.Errorf("failed to make directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) ReadDir(path string) ([]Entry, error) { //nolint
|
||||
panic("implement me")
|
||||
func (l sftpDisk) ReadDir(path string) ([]Entry, error) {
|
||||
slog.Debug("reading directory", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
dir, err := l.client.ReadDir(clean(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list files in directory: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]Entry, len(dir))
|
||||
for i, entry := range dir {
|
||||
entries[i] = sftpEntry{
|
||||
FileInfo: entry,
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l sftpDisk) IsNotExist(err error) bool { //nolint
|
||||
panic("implement me")
|
||||
}
|
||||
func (l sftpDisk) Open(path string, _ int) (io.WriteCloser, error) {
|
||||
slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "sftp"))
|
||||
|
||||
func (l sftpDisk) IsExist(err error) bool { //nolint
|
||||
panic("implement me")
|
||||
}
|
||||
f, err := l.client.Create(clean(path))
|
||||
if err != nil {
|
||||
slog.Error("failed to open file", slog.Any("err", err))
|
||||
}
|
||||
|
||||
func (l sftpDisk) Open(path string, flag int) (io.WriteCloser, error) { //nolint
|
||||
panic("implement me")
|
||||
return f, nil
|
||||
}
|
||||
|
|
|
@ -203,27 +203,27 @@ func (i *Installation) Validate(ctx *GlobalContext) error {
|
|||
|
||||
foundExecutable := false
|
||||
|
||||
err = d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe"))
|
||||
if err != nil {
|
||||
if !d.IsNotExist(err) {
|
||||
exists, err := d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe"))
|
||||
if !exists {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed reading FactoryGame.exe: %w", err)
|
||||
}
|
||||
} else {
|
||||
foundExecutable = true
|
||||
}
|
||||
|
||||
err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh"))
|
||||
if err != nil {
|
||||
if !d.IsNotExist(err) {
|
||||
exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh"))
|
||||
if !exists {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed reading FactoryServer.sh: %w", err)
|
||||
}
|
||||
} else {
|
||||
foundExecutable = true
|
||||
}
|
||||
|
||||
err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe"))
|
||||
if err != nil {
|
||||
if !d.IsNotExist(err) {
|
||||
exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe"))
|
||||
if !exists {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed reading FactoryServer.exe: %w", err)
|
||||
}
|
||||
} else {
|
||||
|
@ -269,16 +269,23 @@ func (i *Installation) LockFile(ctx *GlobalContext) (*resolver.LockFile, error)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
exists, err := d.Exists(lockfilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var lockFile *resolver.LockFile
|
||||
lockFileJSON, err := d.Read(lockfilePath)
|
||||
if err != nil {
|
||||
if !d.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed reading lockfile: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil {
|
||||
return nil, fmt.Errorf("failed parsing lockfile: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed reading lockfile: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil {
|
||||
return nil, fmt.Errorf("failed parsing lockfile: %w", err)
|
||||
}
|
||||
|
||||
return lockFile, nil
|
||||
|
@ -296,7 +303,11 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile *resolver.Lock
|
|||
}
|
||||
|
||||
lockfileDir := filepath.Dir(lockfilePath)
|
||||
if err := d.Exists(lockfileDir); d.IsNotExist(err) {
|
||||
if exists, err := d.Exists(lockfileDir); !exists {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.MkDir(lockfileDir); err != nil {
|
||||
return fmt.Errorf("failed creating lockfile directory: %w", err)
|
||||
}
|
||||
|
@ -315,6 +326,8 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile *resolver.Lock
|
|||
}
|
||||
|
||||
func (i *Installation) Wipe() error {
|
||||
slog.Info("wiping installation", slog.String("path", i.Path))
|
||||
|
||||
d, err := i.GetDisk()
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -412,8 +425,12 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate)
|
|||
if entry.IsDir() {
|
||||
if _, ok := lockfile.Mods[entry.Name()]; !ok {
|
||||
modDir := filepath.Join(modsDirectory, entry.Name())
|
||||
err := d.Exists(filepath.Join(modDir, ".smm"))
|
||||
if err == nil {
|
||||
exists, err := d.Exists(filepath.Join(modDir, ".smm"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
slog.Info("deleting mod", slog.String("mod_reference", entry.Name()))
|
||||
if err := d.Remove(modDir); err != nil {
|
||||
return fmt.Errorf("failed to delete mod directory: %w", err)
|
||||
|
@ -479,16 +496,28 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate)
|
|||
})
|
||||
}
|
||||
|
||||
if err := errg.Wait(); err != nil {
|
||||
return fmt.Errorf("failed to install mods: %w", err)
|
||||
}
|
||||
|
||||
if updates != nil {
|
||||
if i.Vanilla {
|
||||
updates <- InstallUpdate{
|
||||
Type: InstallUpdateTypeOverall,
|
||||
Progress: utils.GenericProgress{
|
||||
Completed: 1,
|
||||
Total: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
channelUsers.Wait()
|
||||
close(updates)
|
||||
}()
|
||||
}
|
||||
|
||||
if err := errg.Wait(); err != nil {
|
||||
return fmt.Errorf("failed to install mods: %w", err)
|
||||
}
|
||||
slog.Info("installation completed", slog.String("path", i.Path))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -534,11 +563,14 @@ func (i *Installation) UpdateMods(ctx *GlobalContext, mods []string) error {
|
|||
func downloadAndExtractMod(modReference string, version string, link string, hash string, modsDirectory string, updates chan<- InstallUpdate, downloadSemaphore chan int, d disk.Disk) error {
|
||||
var downloadUpdates chan utils.GenericProgress
|
||||
|
||||
var wg sync.WaitGroup
|
||||
if updates != nil {
|
||||
// Forward the inner updates as InstallUpdates
|
||||
downloadUpdates = make(chan utils.GenericProgress)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for up := range downloadUpdates {
|
||||
updates <- InstallUpdate{
|
||||
Item: InstallUpdateItem{
|
||||
|
@ -562,7 +594,6 @@ func downloadAndExtractMod(modReference string, version string, link string, has
|
|||
|
||||
var extractUpdates chan utils.GenericProgress
|
||||
|
||||
var wg sync.WaitGroup
|
||||
if updates != nil {
|
||||
// Forward the inner updates as InstallUpdates
|
||||
extractUpdates = make(chan utils.GenericProgress)
|
||||
|
@ -652,11 +683,17 @@ func (i *Installation) GetGameVersion(ctx *GlobalContext) (int, error) {
|
|||
}
|
||||
|
||||
fullPath := filepath.Join(i.BasePath(), platform.VersionPath)
|
||||
exists, err := d.Exists(fullPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return 0, errors.New("game version file does not exist")
|
||||
}
|
||||
|
||||
file, err := d.Read(fullPath)
|
||||
if err != nil {
|
||||
if d.IsNotExist(err) {
|
||||
return 0, fmt.Errorf("could not find game version file: %w", err)
|
||||
}
|
||||
return 0, fmt.Errorf("failed reading version file: %w", err)
|
||||
}
|
||||
|
||||
|
@ -680,12 +717,12 @@ func (i *Installation) GetPlatform(ctx *GlobalContext) (*Platform, error) {
|
|||
|
||||
for _, platform := range platforms {
|
||||
fullPath := filepath.Join(i.BasePath(), platform.VersionPath)
|
||||
err := d.Exists(fullPath)
|
||||
if err != nil {
|
||||
if d.IsNotExist(err) {
|
||||
continue
|
||||
exists, err := d.Exists(fullPath)
|
||||
if !exists {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed detecting version file: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed detecting version file: %w", err)
|
||||
continue
|
||||
}
|
||||
return &platform, nil
|
||||
}
|
||||
|
|
|
@ -2,13 +2,25 @@ package cli
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MarvinJWendt/testza"
|
||||
|
||||
"github.com/satisfactorymodding/ficsit-cli/cfg"
|
||||
)
|
||||
|
||||
// NOTE:
|
||||
//
|
||||
// This code contains sleep.
|
||||
// This is because github actions are special.
|
||||
// They don't properly sync to disk.
|
||||
// And Go is faster than their disk.
|
||||
// So tests are flaky :)
|
||||
// DO NOT REMOVE THE SLEEP!
|
||||
|
||||
func init() {
|
||||
cfg.SetDefaults()
|
||||
}
|
||||
|
@ -19,7 +31,7 @@ func TestInstallationsInit(t *testing.T) {
|
|||
testza.AssertNotNil(t, installations)
|
||||
}
|
||||
|
||||
func TestAddInstallation(t *testing.T) {
|
||||
func TestAddLocalInstallation(t *testing.T) {
|
||||
ctx, err := InitCLI(false)
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
|
@ -39,6 +51,10 @@ func TestAddInstallation(t *testing.T) {
|
|||
|
||||
serverLocation := os.Getenv("SF_DEDICATED_SERVER")
|
||||
if serverLocation != "" {
|
||||
time.Sleep(time.Second)
|
||||
testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods")))
|
||||
time.Sleep(time.Second)
|
||||
|
||||
installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName)
|
||||
testza.AssertNoError(t, err)
|
||||
testza.AssertNotNil(t, installation)
|
||||
|
@ -49,5 +65,101 @@ func TestAddInstallation(t *testing.T) {
|
|||
installation.Vanilla = true
|
||||
err = installation.Install(ctx, installWatcher())
|
||||
testza.AssertNoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
err = ctx.Wipe()
|
||||
testza.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
func TestAddFTPInstallation(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Not supported
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := InitCLI(false)
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
err = ctx.Wipe()
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
err = ctx.ReInit()
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
ctx.Provider = MockProvider{}
|
||||
|
||||
profileName := "InstallationTest"
|
||||
profile, err := ctx.Profiles.AddProfile(profileName)
|
||||
testza.AssertNoError(t, err)
|
||||
testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5"))
|
||||
testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10"))
|
||||
|
||||
serverLocation := os.Getenv("SF_DEDICATED_SERVER")
|
||||
if serverLocation != "" {
|
||||
time.Sleep(time.Second)
|
||||
testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods")))
|
||||
time.Sleep(time.Second)
|
||||
|
||||
installation, err := ctx.Installations.AddInstallation(ctx, "ftp://user:pass@localhost:2121/server", profileName)
|
||||
testza.AssertNoError(t, err)
|
||||
testza.AssertNotNil(t, installation)
|
||||
|
||||
err = installation.Install(ctx, installWatcher())
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
installation.Vanilla = true
|
||||
err = installation.Install(ctx, installWatcher())
|
||||
testza.AssertNoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
err = ctx.Wipe()
|
||||
testza.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
func TestAddSFTPInstallation(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Not supported
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := InitCLI(false)
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
err = ctx.Wipe()
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
err = ctx.ReInit()
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
ctx.Provider = MockProvider{}
|
||||
|
||||
profileName := "InstallationTest"
|
||||
profile, err := ctx.Profiles.AddProfile(profileName)
|
||||
testza.AssertNoError(t, err)
|
||||
testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5"))
|
||||
testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10"))
|
||||
|
||||
serverLocation := os.Getenv("SF_DEDICATED_SERVER")
|
||||
if serverLocation != "" {
|
||||
time.Sleep(time.Second)
|
||||
testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods")))
|
||||
time.Sleep(time.Second)
|
||||
|
||||
installation, err := ctx.Installations.AddInstallation(ctx, "sftp://user:pass@localhost:2222/home/user/server", profileName)
|
||||
testza.AssertNoError(t, err)
|
||||
testza.AssertNotNil(t, installation)
|
||||
|
||||
err = installation.Install(ctx, installWatcher())
|
||||
testza.AssertNoError(t, err)
|
||||
|
||||
installation.Vanilla = true
|
||||
err = installation.Install(ctx, installWatcher())
|
||||
testza.AssertNoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
err = ctx.Wipe()
|
||||
testza.AssertNoError(t, err)
|
||||
}
|
||||
|
|
31
docker-compose-test.yml
Executable file
31
docker-compose-test.yml
Executable file
|
@ -0,0 +1,31 @@
|
|||
version: '2'
|
||||
|
||||
services:
|
||||
ftp:
|
||||
image: fauria/vsftpd:latest
|
||||
ports:
|
||||
- "2020:20"
|
||||
- "2121:21"
|
||||
- "21100-21110:21100-21110"
|
||||
volumes:
|
||||
- ./SatisfactoryDedicatedServer:/home/vsftpd/user/server
|
||||
environment:
|
||||
- FTP_USER=user
|
||||
- FTP_PASS=pass
|
||||
- PASV_ADDRESS=127.0.0.1
|
||||
- PASV_MIN_PORT=21100
|
||||
- PASV_MAX_PORT=21110
|
||||
- LOG_STDOUT=true
|
||||
|
||||
ssh:
|
||||
image: lscr.io/linuxserver/openssh-server:latest
|
||||
ports:
|
||||
- "2222:2222"
|
||||
volumes:
|
||||
- ./SatisfactoryDedicatedServer:/home/user/server
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- PASSWORD_ACCESS=true
|
||||
- USER_PASSWORD=pass
|
||||
- USER_NAME=user
|
5
go.mod
5
go.mod
|
@ -9,14 +9,17 @@ require (
|
|||
github.com/Khan/genqlient v0.6.0
|
||||
github.com/MarvinJWendt/testza v0.5.2
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/avast/retry-go v3.0.0+incompatible
|
||||
github.com/charmbracelet/bubbles v0.17.1
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.9.1
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d
|
||||
github.com/jackc/puddle/v2 v2.2.1
|
||||
github.com/jlaffaye/ftp v0.2.0
|
||||
github.com/lmittmann/tint v1.0.3
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/pkg/sftp v1.13.6
|
||||
github.com/pterm/pterm v0.12.71
|
||||
github.com/puzpuzpuz/xsync/v3 v3.0.2
|
||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f
|
||||
|
@ -24,6 +27,7 @@ require (
|
|||
github.com/satisfactorymodding/ficsit-resolver v0.0.2
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.1
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/sync v0.5.0
|
||||
)
|
||||
|
||||
|
@ -54,6 +58,7 @@ require (
|
|||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
|
|
14
go.sum
14
go.sum
|
@ -40,6 +40,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK
|
|||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
|
@ -97,6 +99,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
|
||||
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
|
@ -104,6 +108,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb
|
|||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
|
@ -155,6 +161,8 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
|
|||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
@ -239,7 +247,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
@ -251,6 +262,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
|
@ -287,6 +299,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
|
@ -297,6 +310,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
|
|
69
utils/io.go
69
utils/io.go
|
@ -14,49 +14,6 @@ import (
|
|||
"github.com/satisfactorymodding/ficsit-cli/cli/disk"
|
||||
)
|
||||
|
||||
type GenericProgress struct {
|
||||
Completed int64
|
||||
Total int64
|
||||
}
|
||||
|
||||
func (gp GenericProgress) Percentage() float64 {
|
||||
if gp.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(gp.Completed) / float64(gp.Total)
|
||||
}
|
||||
|
||||
type Progresser struct {
|
||||
io.Reader
|
||||
Updates chan<- GenericProgress
|
||||
Total int64
|
||||
Running int64
|
||||
}
|
||||
|
||||
func (pt *Progresser) Read(p []byte) (int, error) {
|
||||
n, err := pt.Reader.Read(p)
|
||||
pt.Running += int64(n)
|
||||
|
||||
if err == nil {
|
||||
if pt.Updates != nil {
|
||||
select {
|
||||
case pt.Updates <- GenericProgress{Completed: pt.Running, Total: pt.Total}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func SHA256Data(f io.Reader) (string, error) {
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
|
@ -68,22 +25,29 @@ func SHA256Data(f io.Reader) (string, error) {
|
|||
|
||||
func ExtractMod(f io.ReaderAt, size int64, location string, hash string, updates chan<- GenericProgress, d disk.Disk) error {
|
||||
hashFile := filepath.Join(location, ".smm")
|
||||
hashBytes, err := d.Read(hashFile)
|
||||
|
||||
exists, err := d.Exists(hashFile)
|
||||
if err != nil {
|
||||
if !d.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
hashBytes, err := d.Read(hashFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read .smm mod hash file: %w", err)
|
||||
}
|
||||
} else {
|
||||
|
||||
if hash == string(hashBytes) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.MkDir(location); err != nil {
|
||||
if !d.IsExist(err) {
|
||||
return fmt.Errorf("failed to create mod directory: %s: %w", location, err)
|
||||
}
|
||||
exists, err = d.Exists(location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
if err := d.Remove(location); err != nil {
|
||||
return fmt.Errorf("failed to remove directory: %s: %w", location, err)
|
||||
}
|
||||
|
@ -175,13 +139,12 @@ func writeZipFile(outFileLocation string, file *zip.File, d disk.Disk, updates c
|
|||
}
|
||||
defer inFile.Close()
|
||||
|
||||
progressInReader := &Progresser{
|
||||
Reader: inFile,
|
||||
progressInWriter := &Progresser{
|
||||
Total: int64(file.UncompressedSize64),
|
||||
Updates: updates,
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, progressInReader); err != nil {
|
||||
if _, err := io.Copy(io.MultiWriter(outFile, progressInWriter), inFile); err != nil {
|
||||
return fmt.Errorf("failed to write to file: %s: %w", outFileLocation, err)
|
||||
}
|
||||
|
||||
|
|
38
utils/progress.go
Normal file
38
utils/progress.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
type GenericProgress struct {
|
||||
Completed int64
|
||||
Total int64
|
||||
}
|
||||
|
||||
func (gp GenericProgress) Percentage() float64 {
|
||||
if gp.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(gp.Completed) / float64(gp.Total)
|
||||
}
|
||||
|
||||
var _ io.Writer = (*Progresser)(nil)
|
||||
|
||||
type Progresser struct {
|
||||
Updates chan<- GenericProgress
|
||||
Total int64
|
||||
Running int64
|
||||
}
|
||||
|
||||
func (pt *Progresser) Write(p []byte) (int, error) {
|
||||
pt.Running += int64(len(p))
|
||||
|
||||
if pt.Updates != nil {
|
||||
select {
|
||||
case pt.Updates <- GenericProgress{Completed: pt.Running, Total: pt.Total}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
Loading…
Reference in a new issue