Complete architectural rewrite
This commit is contained in:
parent
c63b1dc190
commit
b426b5414c
5
go.mod
5
go.mod
|
@ -2,7 +2,4 @@ module modman
|
||||||
|
|
||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
require (
|
require sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221223145344-d68ddf116418 // indirect
|
|
||||||
)
|
|
||||||
|
|
6
go.sum
6
go.sum
|
@ -1,5 +1,7 @@
|
||||||
|
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w=
|
||||||
|
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221223145344-d68ddf116418 h1:lAFE8yL+87+JGZROd2Wc3PG2BUWyiKe0xOWTNyVEiAU=
|
sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f h1:/8n8GAgTrAVQTADtXtHsUOg33jPLVd5srITaU2BplfU=
|
||||||
sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221223145344-d68ddf116418/go.mod h1:lniG+VCTpfcWAKKudVYLrS5NIpRx90H3mQklQNn+eK0=
|
sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f/go.mod h1:lniG+VCTpfcWAKKudVYLrS5NIpRx90H3mQklQNn+eK0=
|
||||||
|
|
174
main.go
174
main.go
|
@ -1,12 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
|
@ -21,148 +17,54 @@ type CommandAction func([]string, []string) (string, error)
|
||||||
|
|
||||||
var commands = []Command{
|
var commands = []Command{
|
||||||
{
|
{
|
||||||
Name: "install",
|
Name: "create",
|
||||||
Description: "Install one or more mod archives into modman",
|
|
||||||
Arguments: []string{"game name", "archive path"},
|
|
||||||
IsVarargs: true,
|
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
|
||||||
if len(providedArguments) < len(requiredArguments) {
|
|
||||||
return "", fmt.Errorf("expected %s folowed by at least one %ss",
|
|
||||||
requiredArguments[0], requiredArguments[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
var archivePaths = providedArguments[1:]
|
|
||||||
|
|
||||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
|
||||||
if installError := game.InstallMods(archivePaths); installError != nil {
|
|
||||||
return "", installError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(archivePaths) > 1 {
|
|
||||||
return "mods installed", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "mod installed", nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
Name: "register",
|
|
||||||
Description: "Registers the data directory path of game for management under the given name",
|
Description: "Registers the data directory path of game for management under the given name",
|
||||||
Arguments: []string{"data path", "game name"},
|
Arguments: []string{"data path", "game name"},
|
||||||
IsVarargs: false,
|
IsVarargs: false,
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
||||||
if len(providedArguments) < len(requiredArguments) {
|
if len(providedArguments) != len(requiredArguments) {
|
||||||
return "", fmt.Errorf("expected %s folowed by at least one %ss",
|
return "", fmt.Errorf("expected %s folowed by %s",
|
||||||
requiredArguments[0], requiredArguments[1])
|
requiredArguments[0], requiredArguments[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
var dataPath = providedArguments[0]
|
var dataPath = providedArguments[0]
|
||||||
var gameName = providedArguments[1]
|
var gameName = providedArguments[1]
|
||||||
|
|
||||||
if registerGameError := RegisterGame(gameName, dataPath); registerGameError != nil {
|
if registerGameError := CreateGame(gameName, dataPath); registerGameError != nil {
|
||||||
return "", registerGameError
|
return "", registerGameError
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("Registered %s at %s", gameName, dataPath), nil
|
return fmt.Sprintf("Created %s at %s", gameName, dataPath), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
Name: "remove",
|
Name: "remove",
|
||||||
Description: "Remove one or more mods from modman",
|
Description: "Cleans and unregisters the given game name from management",
|
||||||
Arguments: []string{"game name", "mod name"},
|
Arguments: []string{"game name"},
|
||||||
IsVarargs: true,
|
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
|
||||||
if len(providedArguments) < len(requiredArguments) {
|
|
||||||
return "", fmt.Errorf("expected %s followed by one or more %ss",
|
|
||||||
requiredArguments[0], requiredArguments[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
var modNames = providedArguments[1:]
|
|
||||||
|
|
||||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
|
||||||
for _, name := range modNames {
|
|
||||||
if removeError := game.RemoveMod(name); removeError != nil {
|
|
||||||
return "", removeError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "", openGameError
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(modNames) > 1 {
|
|
||||||
return "removed mods", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "removed mod", nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
Name: "rename",
|
|
||||||
Description: "Rename a mod within modman",
|
|
||||||
Arguments: []string{"game name", "mod name", "new name"},
|
|
||||||
IsVarargs: false,
|
IsVarargs: false,
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
||||||
if len(providedArguments) != len(requiredArguments) {
|
if len(providedArguments) != len(requiredArguments) {
|
||||||
return "", fmt.Errorf("expected %s followed by %s and %s",
|
return "", fmt.Errorf("expected %s", requiredArguments[0])
|
||||||
requiredArguments[0], requiredArguments[1], requiredArguments[2])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
var dataPath = providedArguments[0]
|
||||||
if renameError := game.RenameMod(providedArguments[1], providedArguments[2]); renameError != nil {
|
var gameName = providedArguments[1]
|
||||||
return "", renameError
|
|
||||||
}
|
if registerGameError := RemoveGame(gameName); registerGameError != nil {
|
||||||
} else {
|
return "", registerGameError
|
||||||
return "", openGameError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "renamed", nil
|
return fmt.Sprintf("Removed %s at %s", gameName, dataPath), nil
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
Name: "manifest",
|
|
||||||
Description: "Retrieve a manifest of all installed mods",
|
|
||||||
Arguments: []string{"game name", "format"},
|
|
||||||
IsVarargs: false,
|
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
|
||||||
if len(providedArguments) != len(requiredArguments) {
|
|
||||||
return "", fmt.Errorf("expected %s followed by %s",
|
|
||||||
requiredArguments[0], requiredArguments[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifestBuilder = strings.Builder{}
|
|
||||||
|
|
||||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
|
||||||
var format = providedArguments[1]
|
|
||||||
|
|
||||||
if formatter, exists := formatters[format]; exists {
|
|
||||||
if formatError := formatter(&manifestBuilder, game.Mods); formatError != nil {
|
|
||||||
return "", formatError
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "", fmt.Errorf("unsupported format: `%s`", format)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "", openGameError
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifestBuilder.String(), nil
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
Name: "deploy",
|
Name: "deploy",
|
||||||
Description: "Deploy all specified mods in order of listing",
|
Description: "Deploys all specified mod archives in order of listing",
|
||||||
Arguments: []string{"game name", "mod name"},
|
Arguments: []string{"game name", "archive paths"},
|
||||||
IsVarargs: true,
|
IsVarargs: true,
|
||||||
|
|
||||||
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
Action: func(requiredArguments []string, providedArguments []string) (string, error) {
|
||||||
|
@ -171,12 +73,10 @@ var commands = []Command{
|
||||||
requiredArguments[0], requiredArguments[1])
|
requiredArguments[0], requiredArguments[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
var deployError = DeployGameMods(providedArguments[0], providedArguments[1:])
|
||||||
if deployError := game.Deploy(providedArguments[1:]); deployError != nil {
|
|
||||||
return "", deployError
|
if deployError != nil {
|
||||||
}
|
return "", deployError
|
||||||
} else {
|
|
||||||
return "", openGameError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "deployed", nil
|
return "deployed", nil
|
||||||
|
@ -194,12 +94,10 @@ var commands = []Command{
|
||||||
return "", fmt.Errorf("expected %s", requiredArguments[0])
|
return "", fmt.Errorf("expected %s", requiredArguments[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if game, openGameError := LoadGame(arguments[0]); openGameError == nil {
|
var cleanError = CleanGameMods(arguments[0])
|
||||||
if cleanError := game.Clean(); cleanError != nil {
|
|
||||||
return "", cleanError
|
if cleanError != nil {
|
||||||
}
|
return "", cleanError
|
||||||
} else {
|
|
||||||
return "", openGameError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "cleaned", nil
|
return "cleaned", nil
|
||||||
|
@ -207,30 +105,6 @@ var commands = []Command{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var formatters = map[string]func(*strings.Builder, any) error{
|
|
||||||
"json": func(builder *strings.Builder, data any) error {
|
|
||||||
var encoder = json.NewEncoder(builder)
|
|
||||||
|
|
||||||
if encodeError := encoder.Encode(data); encodeError != nil {
|
|
||||||
return encodeError
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
|
|
||||||
"yaml": func(builder *strings.Builder, data any) error {
|
|
||||||
var encoder = yaml.NewEncoder(builder)
|
|
||||||
|
|
||||||
encoder.SetIndent(2)
|
|
||||||
|
|
||||||
if encodeError := encoder.Encode(data); encodeError != nil {
|
|
||||||
return encodeError
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var argCount = len(os.Args)
|
var argCount = len(os.Args)
|
||||||
|
|
||||||
|
|
713
manager.go
713
manager.go
|
@ -3,62 +3,203 @@ package main
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sauce.pizzawednes.day/kayomn/ini-gusher"
|
"sauce.pizzawednes.day/kayomn/ini-gusher"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (game *Game) Clean() error {
|
const appName = "modman"
|
||||||
// Clean up currently deployed files first.
|
|
||||||
for _, filePath := range game.DeployedFilePaths {
|
|
||||||
if removeError := os.Remove(filepath.Join(
|
|
||||||
game.Path, filePath)); (removeError != nil) && (!(os.IsNotExist(removeError))) {
|
|
||||||
|
|
||||||
return removeError
|
var errGameNotFound = errors.New("game not found")
|
||||||
|
|
||||||
|
var gamesIniPath = filepath.Join(configDirPath(), "games.ini")
|
||||||
|
|
||||||
|
func CleanGameMods(gameName string) error {
|
||||||
|
var deployDirPath, pathError = gameDataPath(gameName)
|
||||||
|
|
||||||
|
if pathError != nil {
|
||||||
|
return pathError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop over overwrite dir and move files back.
|
||||||
|
var cacheDirPath = filepath.Join(cacheDirPath(), gameName)
|
||||||
|
var stageDirPath = filepath.Join(cacheDirPath, "staged")
|
||||||
|
|
||||||
|
if walkError := filepath.WalkDir(stageDirPath, func(
|
||||||
|
path string, dirEntry fs.DirEntry, err error) error {
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dirEntry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath, relativeError = filepath.Rel(stageDirPath, path)
|
||||||
|
|
||||||
|
if relativeError != nil {
|
||||||
|
return relativeError
|
||||||
|
}
|
||||||
|
|
||||||
|
if removeError := os.Remove(filepath.Join(
|
||||||
|
deployDirPath, relativePath)); removeError != nil {
|
||||||
|
|
||||||
|
if !(os.IsNotExist(removeError)) {
|
||||||
|
return removeError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); walkError != nil {
|
||||||
|
if !(os.IsNotExist(walkError)) {
|
||||||
|
return walkError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
game.DeployedFilePaths = game.DeployedFilePaths[:0]
|
if removeError := os.RemoveAll(stageDirPath); removeError != nil {
|
||||||
|
return removeError
|
||||||
|
}
|
||||||
|
|
||||||
var overwriteDirPath = filepath.Join(cachePath(), game.Name, "overwritten")
|
var overwriteDirPath = filepath.Join(cacheDirPath, "overwritten")
|
||||||
|
|
||||||
// Then restore all files overwritten by previously deployment.
|
if walkError := filepath.WalkDir(overwriteDirPath, func(
|
||||||
for _, filePath := range game.OverwrittenFilePaths {
|
path string, dirEntry fs.DirEntry, err error) error {
|
||||||
if renameError := os.Rename(filepath.Join(overwriteDirPath, filePath),
|
|
||||||
filepath.Join(game.Path, filePath)); renameError != nil {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dirEntry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath, relativeError = filepath.Rel(overwriteDirPath, path)
|
||||||
|
|
||||||
|
if relativeError != nil {
|
||||||
|
return relativeError
|
||||||
|
}
|
||||||
|
|
||||||
|
if renameError := os.Rename(path, filepath.Join(
|
||||||
|
deployDirPath, relativePath)); renameError != nil {
|
||||||
|
|
||||||
return renameError
|
return renameError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); walkError != nil {
|
||||||
|
if !(os.IsNotExist(walkError)) {
|
||||||
|
return walkError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
game.OverwrittenFilePaths = game.OverwrittenFilePaths[:0]
|
if removeError := os.RemoveAll(overwriteDirPath); removeError != nil {
|
||||||
|
return removeError
|
||||||
|
}
|
||||||
|
|
||||||
return game.saveDeployment()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (game *Game) Deploy(names []string) error {
|
func CreateGame(gameName string, gameDataPath string) error {
|
||||||
if cleanError := game.Clean(); cleanError != nil {
|
var gameDataPaths = make(map[string]string)
|
||||||
return cleanError
|
|
||||||
|
if fileInfo, statError := os.Stat(gameDataPath); statError == nil {
|
||||||
|
if !(fileInfo.IsDir()) {
|
||||||
|
return fmt.Errorf("game data path must be a valid directory")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return statError
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range names {
|
if iniFile, openError := os.Open(gamesIniPath); openError == nil {
|
||||||
var mod, exists = game.Mods[name]
|
if (openError != nil) && !(os.IsNotExist(openError)) {
|
||||||
|
return openError
|
||||||
if !(exists) {
|
|
||||||
return fmt.Errorf("mod does not exist: %s", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var installPath = fmt.Sprintf("%s.%s",
|
defer func() {
|
||||||
filepath.Join(configPath(), game.Name, name), mod.Format)
|
if closeError := iniFile.Close(); closeError != nil {
|
||||||
|
panic(closeError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch mod.Format {
|
var parser = ini.NewParser(iniFile)
|
||||||
|
|
||||||
|
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
|
||||||
|
var section = parser.Section()
|
||||||
|
|
||||||
|
if section == gameName {
|
||||||
|
return fmt.Errorf("`%s` is already used by a game", section)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Key == "path" {
|
||||||
|
gameDataPaths[section] = entry.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseError := parser.Err(); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
} else if !(os.IsNotExist(openError)) {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
|
||||||
|
gameDataPaths[gameName] = gameDataPath
|
||||||
|
|
||||||
|
return saveGames(gameDataPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeployGameMods(gameName string, modArchivePaths []string) error {
|
||||||
|
var deployDirPath, pathError = gameDataPath(gameName)
|
||||||
|
|
||||||
|
if pathError != nil {
|
||||||
|
return pathError
|
||||||
|
}
|
||||||
|
|
||||||
|
var overwrittenFilePaths = make(map[string]bool)
|
||||||
|
var cacheDirPath = filepath.Join(cacheDirPath(), gameName)
|
||||||
|
var overwriteDirPath = filepath.Join(cacheDirPath, "overwritten")
|
||||||
|
|
||||||
|
if walkError := filepath.WalkDir(overwriteDirPath, func(
|
||||||
|
path string, dirEntry fs.DirEntry, err error) error {
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(dirEntry.IsDir()) {
|
||||||
|
var relativePath, relativeError = filepath.Rel(overwriteDirPath, path)
|
||||||
|
|
||||||
|
if relativeError != nil {
|
||||||
|
return relativeError
|
||||||
|
}
|
||||||
|
|
||||||
|
overwrittenFilePaths[relativePath] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); walkError != nil {
|
||||||
|
if !(os.IsNotExist(walkError)) {
|
||||||
|
return walkError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stageDirPath = filepath.Join(cacheDirPath, "staged")
|
||||||
|
|
||||||
|
for _, archivePath := range modArchivePaths {
|
||||||
|
var archiveExtension = filepath.Ext(archivePath)
|
||||||
|
|
||||||
|
if len(archiveExtension) == 0 {
|
||||||
|
return fmt.Errorf("cannot infer archive format: `%s`", archivePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch archiveExtension[1:] {
|
||||||
case "zip":
|
case "zip":
|
||||||
var zipReadCloser, openError = zip.OpenReader(installPath)
|
var zipReadCloser, openError = zip.OpenReader(archivePath)
|
||||||
|
|
||||||
if openError != nil {
|
if openError != nil {
|
||||||
return openError
|
return openError
|
||||||
|
@ -73,9 +214,11 @@ func (game *Game) Deploy(names []string) error {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for _, zipFile := range zipReadCloser.File {
|
for _, zipFile := range zipReadCloser.File {
|
||||||
var deployPath = filepath.Join(game.Path, zipFile.Name)
|
var stagePath = filepath.Join(stageDirPath, zipFile.Name)
|
||||||
|
|
||||||
|
if dirError := os.MkdirAll(
|
||||||
|
filepath.Dir(stagePath), os.ModePerm); dirError != nil {
|
||||||
|
|
||||||
if dirError := os.MkdirAll(filepath.Dir(deployPath), os.ModePerm); dirError != nil {
|
|
||||||
return dirError
|
return dirError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,376 +227,143 @@ func (game *Game) Deploy(names []string) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup up any pre-existing file before it is overwritten by Zip entry
|
var entryReadCloser, openError = zipFile.Open()
|
||||||
// extraction.
|
|
||||||
if _, statError := os.Stat(deployPath); statError == nil {
|
|
||||||
var backupPath = filepath.Join(
|
|
||||||
cachePath(), game.Name, "overwritten", zipFile.Name)
|
|
||||||
|
|
||||||
if renameError := os.Rename(deployPath, backupPath); renameError != nil {
|
if openError != nil {
|
||||||
return renameError
|
return openError
|
||||||
}
|
|
||||||
|
|
||||||
game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, zipFile.Name)
|
|
||||||
} else if !(os.IsNotExist(statError)) {
|
|
||||||
return statError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if file, createError := os.Create(deployPath); createError == nil {
|
defer func() {
|
||||||
var zipFile, ioError = zipFile.Open()
|
if closeError := entryReadCloser.Close(); closeError != nil {
|
||||||
|
// Zip entry read closer will not have any pending I/O operations nor is it
|
||||||
|
// possible to have already been closed.
|
||||||
|
panic(closeError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if ioError == nil {
|
if stagingFile, createError := os.Create(stagePath); createError == nil {
|
||||||
_, ioError = io.Copy(file, zipFile)
|
if _, copyError := io.Copy(stagingFile, entryReadCloser); copyError != nil {
|
||||||
|
stagingFile.Sync()
|
||||||
|
stagingFile.Close()
|
||||||
|
|
||||||
|
return copyError
|
||||||
}
|
}
|
||||||
|
|
||||||
if syncError := file.Sync(); (syncError != nil) && (ioError == nil) {
|
if syncError := stagingFile.Sync(); syncError != nil {
|
||||||
ioError = syncError
|
stagingFile.Close()
|
||||||
|
|
||||||
|
return syncError
|
||||||
}
|
}
|
||||||
|
|
||||||
if closeError := file.Close(); (closeError != nil) && (ioError == nil) {
|
if closeError := stagingFile.Close(); closeError != nil {
|
||||||
ioError = closeError
|
|
||||||
}
|
|
||||||
|
|
||||||
if closeError := zipFile.Close(); closeError != nil {
|
|
||||||
ioError = closeError
|
|
||||||
}
|
|
||||||
|
|
||||||
if ioError != nil {
|
|
||||||
return ioError
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if closeError := zipReadCloser.Close(); closeError != nil {
|
|
||||||
return closeError
|
return closeError
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
return createError
|
return createError
|
||||||
}
|
}
|
||||||
|
|
||||||
game.DeployedFilePaths = append(game.DeployedFilePaths, zipFile.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported mod format: %s", mod.Format)
|
return fmt.Errorf("unrecognized archive format: `%s`", archivePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return game.saveDeployment()
|
if walkError := filepath.WalkDir(stageDirPath, func(
|
||||||
}
|
path string, dirEntry fs.DirEntry, err error) error {
|
||||||
|
|
||||||
type Game struct {
|
if dirEntry.IsDir() {
|
||||||
Name string
|
return nil
|
||||||
DeployedFilePaths []string
|
|
||||||
OverwrittenFilePaths []string
|
|
||||||
Mods map[string]Mod
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (game *Game) InstallMods(archivePaths []string) error {
|
|
||||||
var installDirPath = filepath.Join(configPath(), game.Name)
|
|
||||||
|
|
||||||
for _, archivePath := range archivePaths {
|
|
||||||
var archiveName = filepath.Base(archivePath)
|
|
||||||
var archiveExtension = filepath.Ext(archiveName)
|
|
||||||
|
|
||||||
if len(archiveExtension) == 0 {
|
|
||||||
return fmt.Errorf("unknown archive format: %s", archiveExtension)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = strings.TrimSuffix(archiveName, archiveExtension)
|
var relativePath, relativeError = filepath.Rel(stageDirPath, path)
|
||||||
|
|
||||||
if _, exists := game.Mods[name]; exists {
|
if relativeError != nil {
|
||||||
return fmt.Errorf("mod with name already exists: `%s`", name)
|
return relativeError
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy archive into installation directory.
|
var deployFilePath = filepath.Join(deployDirPath, relativePath)
|
||||||
if archiveFile, openError := os.Open(archivePath); openError == nil {
|
|
||||||
defer func() {
|
if dirError := os.MkdirAll(filepath.Dir(deployFilePath), os.ModePerm); dirError != nil {
|
||||||
if closeError := archiveFile.Close(); closeError != nil {
|
return dirError
|
||||||
// Archive file will not have any pending I/O operations nor is it possible to have
|
}
|
||||||
// already been closed.
|
|
||||||
panic(closeError)
|
if isOverwrittenFilePath := overwrittenFilePaths[relativePath]; !(isOverwrittenFilePath) {
|
||||||
|
var ovewriteFilePath = filepath.Join(overwriteDirPath, relativePath)
|
||||||
|
|
||||||
|
if _, statError := os.Stat(ovewriteFilePath); statError == nil {
|
||||||
|
if dirError := os.MkdirAll(
|
||||||
|
filepath.Dir(ovewriteFilePath), os.ModePerm); dirError != nil {
|
||||||
|
|
||||||
|
return dirError
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
var archiveFileInfo, statError = archiveFile.Stat()
|
if renameError := os.Rename(deployFilePath, ovewriteFilePath); renameError != nil {
|
||||||
|
return renameError
|
||||||
if statError != nil {
|
}
|
||||||
|
} else if !(os.IsNotExist(statError)) {
|
||||||
return statError
|
return statError
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if dirError := os.MkdirAll(installDirPath, archiveFileInfo.Mode()); dirError != nil {
|
if removeError := os.Remove(deployFilePath); removeError != nil {
|
||||||
return dirError
|
if !(os.IsNotExist(removeError)) {
|
||||||
|
return removeError
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if installFile, createError := os.Create(filepath.Join(installDirPath, archiveName)); createError == nil {
|
if linkError := os.Link(path, deployFilePath); linkError != nil {
|
||||||
var _, copyError = io.Copy(installFile, archiveFile)
|
return linkError
|
||||||
var syncError = installFile.Sync()
|
}
|
||||||
var closeError = installFile.Close()
|
|
||||||
|
|
||||||
if (copyError != nil) || (syncError != nil) {
|
return nil
|
||||||
return fmt.Errorf("failed to install mod")
|
}); walkError != nil {
|
||||||
}
|
if !(os.IsNotExist(walkError)) {
|
||||||
|
return walkError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if closeError != nil {
|
return nil
|
||||||
return closeError
|
}
|
||||||
}
|
|
||||||
} else {
|
func RemoveGame(gameName string) error {
|
||||||
return createError
|
var gameDataPaths = make(map[string]string)
|
||||||
}
|
|
||||||
} else {
|
if iniFile, openError := os.Open(gamesIniPath); openError == nil {
|
||||||
|
if (openError != nil) && !(os.IsNotExist(openError)) {
|
||||||
return openError
|
return openError
|
||||||
}
|
}
|
||||||
|
|
||||||
game.Mods[name] = Mod{
|
|
||||||
Format: archiveExtension[1:],
|
|
||||||
Source: archivePath,
|
|
||||||
Version: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return game.saveMods()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (game *Game) Load() error {
|
|
||||||
// Read deployed files from disk.
|
|
||||||
var deployedListPath = filepath.Join(cachePath(), game.Name, "deployed.txt")
|
|
||||||
|
|
||||||
if file, openError := os.Open(deployedListPath); openError == nil {
|
|
||||||
for scanner := bufio.NewScanner(file); scanner.Scan(); {
|
|
||||||
game.DeployedFilePaths = append(game.DeployedFilePaths, scanner.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
if closeError := file.Close(); closeError != nil {
|
|
||||||
return closeError
|
|
||||||
}
|
|
||||||
} else if !(os.IsNotExist(openError)) {
|
|
||||||
return openError
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read overwritten game files from disk.
|
|
||||||
var overwrittenFilesDirPath = filepath.Join(cachePath(), game.Name, "overwritten")
|
|
||||||
|
|
||||||
if _, statError := os.Stat(overwrittenFilesDirPath); statError == nil {
|
|
||||||
if walkError := filepath.WalkDir(overwrittenFilesDirPath, func(
|
|
||||||
path string, dirEntry fs.DirEntry, walkError error) error {
|
|
||||||
|
|
||||||
if walkError != nil {
|
|
||||||
return walkError
|
|
||||||
}
|
|
||||||
|
|
||||||
if !(dirEntry.IsDir()) {
|
|
||||||
game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); walkError != nil {
|
|
||||||
return walkError
|
|
||||||
}
|
|
||||||
} else if !(os.IsNotExist(statError)) {
|
|
||||||
return statError
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read mod info from disk.
|
|
||||||
if file, openError := os.Open(filepath.Join(configPath(), game.Name, "mods.ini")); openError == nil {
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if closeError := file.Close(); closeError != nil {
|
if closeError := iniFile.Close(); closeError != nil {
|
||||||
// File will not have any pending I/O operations nor is it possible to have already
|
|
||||||
// been closed.
|
|
||||||
panic(closeError)
|
panic(closeError)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var parser = ini.NewParser(file)
|
var parser = ini.NewParser(iniFile)
|
||||||
|
|
||||||
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
|
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
|
||||||
var mod = game.Mods[entry.Section]
|
var section = parser.Section()
|
||||||
|
|
||||||
switch entry.Key {
|
if (section != gameName) && (entry.Key == "path") {
|
||||||
case "format":
|
gameDataPaths[section] = entry.Value
|
||||||
mod.Format = entry.Value
|
|
||||||
|
|
||||||
case "source":
|
|
||||||
mod.Source = entry.Value
|
|
||||||
|
|
||||||
case "version":
|
|
||||||
mod.Version = entry.Value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
game.Mods[entry.Section] = mod
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if parserError := parser.Err(); parserError != nil {
|
if parseError := parser.Err(); parseError != nil {
|
||||||
return parserError
|
return parseError
|
||||||
}
|
}
|
||||||
} else if !(os.IsNotExist(openError)) {
|
} else if !(os.IsNotExist(openError)) {
|
||||||
return openError
|
return openError
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return saveGames(gameDataPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mod struct {
|
func cacheDirPath() string {
|
||||||
Format string
|
|
||||||
Source string
|
|
||||||
Version string
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadGame(name string) (Game, error) {
|
|
||||||
var configPath, configPathError = os.UserConfigDir()
|
|
||||||
|
|
||||||
if configPathError != nil {
|
|
||||||
return Game{}, configPathError
|
|
||||||
}
|
|
||||||
|
|
||||||
var gamesFile, gamesOpenError = os.Open(filepath.Join(configPath, "modman", "games.ini"))
|
|
||||||
|
|
||||||
if gamesOpenError != nil {
|
|
||||||
if os.IsNotExist(gamesOpenError) {
|
|
||||||
return Game{}, fmt.Errorf("no games registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
return Game{}, gamesOpenError
|
|
||||||
}
|
|
||||||
|
|
||||||
var gamesParser = ini.NewParser(gamesFile)
|
|
||||||
|
|
||||||
for entry := gamesParser.Parse(); !(gamesParser.IsEnd()); entry = gamesParser.Parse() {
|
|
||||||
if (entry.Key == "path") && (entry.Section == name) {
|
|
||||||
var game = Game{
|
|
||||||
Name: name,
|
|
||||||
OverwrittenFilePaths: make([]string, 0, 512),
|
|
||||||
DeployedFilePaths: make([]string, 0, 512),
|
|
||||||
Mods: make(map[string]Mod),
|
|
||||||
Path: entry.Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
if loadError := game.Load(); loadError != nil {
|
|
||||||
return Game{}, loadError
|
|
||||||
}
|
|
||||||
|
|
||||||
return game, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Game{}, fmt.Errorf("game not registered: %s", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (game *Game) RemoveMod(name string) error {
|
|
||||||
if _, exists := game.Mods[name]; !(exists) {
|
|
||||||
return fmt.Errorf("unknown mod: `%s`", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if removeError := os.RemoveAll(filepath.Join(
|
|
||||||
configPath(), game.Name, name)); removeError != nil {
|
|
||||||
|
|
||||||
return removeError
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(game.Mods, name)
|
|
||||||
|
|
||||||
return game.saveMods()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterGame(name string, dataPath string) error {
|
|
||||||
var gamesPath = filepath.Join(configPath(), "games.ini")
|
|
||||||
var gameNamePaths = make(map[string]string)
|
|
||||||
|
|
||||||
if file, openError := os.Open(gamesPath); openError == nil {
|
|
||||||
defer func() {
|
|
||||||
if closeError := file.Close(); closeError != nil {
|
|
||||||
// No way for this to fail.
|
|
||||||
panic(closeError)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var parser = ini.NewParser(file)
|
|
||||||
|
|
||||||
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
|
|
||||||
if entry.Key == "path" {
|
|
||||||
gameNamePaths[entry.Section] = entry.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parserError := parser.Err(); parserError != nil {
|
|
||||||
return parserError
|
|
||||||
}
|
|
||||||
} else if !(os.IsNotExist(openError)) {
|
|
||||||
return openError
|
|
||||||
}
|
|
||||||
|
|
||||||
if file, updateError := os.Create(gamesPath); updateError == nil {
|
|
||||||
var writer = bufio.NewWriter(file)
|
|
||||||
var builder = ini.NewBuilder(writer)
|
|
||||||
|
|
||||||
for name, path := range gameNamePaths {
|
|
||||||
if buildError := builder.Section(name); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildError := builder.KeyValue("path", path); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if writeError := writer.Flush(); (writeError != nil) && (updateError == nil) {
|
|
||||||
updateError = writeError
|
|
||||||
}
|
|
||||||
|
|
||||||
if syncError := file.Sync(); (syncError != nil) && (updateError == nil) {
|
|
||||||
updateError = syncError
|
|
||||||
}
|
|
||||||
|
|
||||||
if closeError := file.Close(); (closeError != nil) && (updateError == nil) {
|
|
||||||
updateError = closeError
|
|
||||||
}
|
|
||||||
|
|
||||||
if updateError != nil {
|
|
||||||
return updateError
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return updateError
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (game *Game) RenameMod(modName string, newName string) error {
|
|
||||||
var mod, exists = game.Mods[modName]
|
|
||||||
|
|
||||||
if !(exists) {
|
|
||||||
return fmt.Errorf("no mod with that name exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, nameTaken := game.Mods[newName]; nameTaken {
|
|
||||||
return fmt.Errorf("a mod with the new name already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
var modsDirPath = filepath.Join(configPath(), game.Name)
|
|
||||||
|
|
||||||
if renameError := os.Rename(fmt.Sprintf("%s.%s",
|
|
||||||
filepath.Join(modsDirPath, modName), mod.Format),
|
|
||||||
fmt.Sprintf("%s.%s", filepath.Join(
|
|
||||||
modsDirPath, newName), mod.Format)); renameError != nil {
|
|
||||||
|
|
||||||
return renameError
|
|
||||||
}
|
|
||||||
|
|
||||||
game.Mods[newName] = mod
|
|
||||||
|
|
||||||
delete(game.Mods, modName)
|
|
||||||
|
|
||||||
return game.saveMods()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cachePath() string {
|
|
||||||
return fallbackPath(os.UserCacheDir, "cache")
|
return fallbackPath(os.UserCacheDir, "cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
func configPath() string {
|
func configDirPath() string {
|
||||||
return fallbackPath(os.UserConfigDir, "config")
|
return fallbackPath(os.UserConfigDir, "config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -469,107 +379,68 @@ func fallbackPath(getFalliblePath func() (string, error), fallbackSubdir string)
|
||||||
panic(pathError)
|
panic(pathError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(path, "modman", fallbackSubdir)
|
return filepath.Join(path, appName, fallbackSubdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(path, "modman")
|
return filepath.Join(path, appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (game *Game) saveDeployment() error {
|
func gameDataPath(gameName string) (string, error) {
|
||||||
var listPath = filepath.Join(cachePath(), game.Name, "deployed.txt")
|
var gamesFile, openError = os.Open(gamesIniPath)
|
||||||
|
|
||||||
if len(game.DeployedFilePaths) == 0 {
|
if (openError != nil) && !(os.IsNotExist(openError)) {
|
||||||
var truncateError = os.Truncate(listPath, 0)
|
return "", openError
|
||||||
|
}
|
||||||
|
|
||||||
if !(os.IsNotExist(truncateError)) {
|
defer func() {
|
||||||
return truncateError
|
if closeError := gamesFile.Close(); closeError != nil {
|
||||||
|
panic(closeError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var parser = ini.NewParser(gamesFile)
|
||||||
|
|
||||||
|
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
|
||||||
|
if (parser.Section() == gameName) && (entry.Key == "path") {
|
||||||
|
return entry.Value, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if dirError := os.MkdirAll(filepath.Dir(listPath), os.ModePerm); dirError != nil {
|
if parseError := parser.Err(); parseError != nil {
|
||||||
return dirError
|
return "", parseError
|
||||||
}
|
}
|
||||||
|
|
||||||
if file, updateError := os.Create(listPath); updateError == nil {
|
return "", errGameNotFound
|
||||||
var writer = bufio.NewWriter(file)
|
}
|
||||||
|
|
||||||
for _, filePath := range game.DeployedFilePaths {
|
func saveGames(gameDataPaths map[string]string) error {
|
||||||
if _, printError := fmt.Fprintln(writer, filePath); printError != nil {
|
if iniFile, createError := os.Create(gamesIniPath); createError == nil {
|
||||||
updateError = printError
|
var iniWriter = bufio.NewWriter(iniFile)
|
||||||
|
var iniBuilder = ini.NewBuilder(iniWriter)
|
||||||
|
|
||||||
break
|
for name, dataPath := range gameDataPaths {
|
||||||
}
|
iniBuilder.Section(name)
|
||||||
|
iniBuilder.KeyValue("path", dataPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flushError := writer.Flush(); flushError != nil && updateError == nil {
|
if flushError := iniWriter.Flush(); flushError != nil {
|
||||||
updateError = flushError
|
iniFile.Close()
|
||||||
|
|
||||||
|
return flushError
|
||||||
}
|
}
|
||||||
|
|
||||||
if syncError := file.Sync(); syncError != nil && updateError == nil {
|
if syncError := iniFile.Sync(); syncError != nil {
|
||||||
updateError = syncError
|
iniFile.Close()
|
||||||
|
|
||||||
|
return syncError
|
||||||
}
|
}
|
||||||
|
|
||||||
if closeError := file.Close(); closeError != nil && updateError == nil {
|
if closeError := iniFile.Close(); closeError != nil {
|
||||||
updateError = closeError
|
return closeError
|
||||||
}
|
|
||||||
|
|
||||||
if updateError != nil {
|
|
||||||
return updateError
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return updateError
|
return createError
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (game *Game) saveMods() error {
|
|
||||||
var file, updateError = os.Create(filepath.Join(configPath(), game.Name, "mods.ini"))
|
|
||||||
|
|
||||||
if updateError != nil {
|
|
||||||
return updateError
|
|
||||||
}
|
|
||||||
|
|
||||||
var writer = bufio.NewWriter(file)
|
|
||||||
var builder = ini.NewBuilder(writer)
|
|
||||||
|
|
||||||
for name, mod := range game.Mods {
|
|
||||||
if buildError := builder.Section(name); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildError := builder.KeyValue("format", mod.Format); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildError := builder.KeyValue("source", mod.Source); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildError := builder.KeyValue("version", mod.Version); buildError != nil {
|
|
||||||
updateError = buildError
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if writeError := writer.Flush(); (writeError != nil) && (updateError == nil) {
|
|
||||||
updateError = writeError
|
|
||||||
}
|
|
||||||
|
|
||||||
if syncError := file.Sync(); (syncError != nil) && (updateError == nil) {
|
|
||||||
updateError = syncError
|
|
||||||
}
|
|
||||||
|
|
||||||
if closeError := file.Close(); (closeError != nil) && (updateError == nil) {
|
|
||||||
updateError = closeError
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateError
|
|
||||||
}
|
|
||||||
|
|
52
readme.md
52
readme.md
|
@ -5,3 +5,55 @@ A simple command-line interface for managing and deploying mods to a Elder Scrol
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
A readout of the operations that are supported by Modman can be seen by invoking the `modman` command with no arguments.
|
A readout of the operations that are supported by Modman can be seen by invoking the `modman` command with no arguments.
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
Upon initial setup, you will need to create a new game association using `modman create` followed by the name you want to give the game association and the path to its data folder.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
modman create skyrim "~/.steam/steam/steamapps/common/Skyrim Special Edition/Data/"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Example of how to register an installation of Skyrim Special Edition with Modman
|
||||||
|
|
||||||
|
The game name can contain any valid Unicode characters except whitespace when created without double-quotes surrounding it. Meanwhile, the path must be a valid OS-native path to the game data directory.
|
||||||
|
|
||||||
|
### Deploying Mods
|
||||||
|
|
||||||
|
Once a game association has been created, archives of mods can be deployed directly to the game install via `modman deploy` followed by the game association name and then one or more paths to mod archives.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
modman deploy skyrim "~/downloads/Mod1.zip" "~/downloads/Mod2.zip" "~/downloads/Mod3.zip"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Example of how to deploy mods archives to an associated game install.
|
||||||
|
|
||||||
|
Deploy respects the order of the mods list provided to it and will layer them one after the other. In this scenario, `Mod3.zip` will overwrite any files that share the same paths that `Mod2.zip` do, which in turn overwrites any files that share the same paths as `Mod1.zip`.
|
||||||
|
|
||||||
|
Deployments are also transitive, so running `modman deploy` individually each time for `"~/downloads/Mod1.zip"`, `"~/downloads/Mod2.zip"`, and `"~/downloads/Mod3.zip"` will have the same effect as deploying them once in the same command.
|
||||||
|
|
||||||
|
### Cleaning Up
|
||||||
|
|
||||||
|
All mod files managed by Modman can be completely cleaned from an install using `modman clean` followed by the game association name. This will **completely remove** all mod files managed by Modman, so use with care.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
modman clean skyrim
|
||||||
|
```
|
||||||
|
|
||||||
|
> Example of how to clean managed mods from an associated game install.
|
||||||
|
|
||||||
|
Modman does not provide any way to incrementally remove installed mods in the same way that it does with deployment. This is done to avoid corrupting file overwrite precidence, which can become very messy for any mod management tool that compiles all mods into a merged staging directory like Modman.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Modman uses a staged deployment system and aims for incremental deployment of mods and atomic, transactional cleanup.
|
||||||
|
|
||||||
|
As mod archives are deployed, their contents are extracted and written to a merged `staged` directory inside the user cache folder, overwriting any files with the same file paths that came before them. After staging, hard links to those files are written into the game directory after any files overwritten by the staged mods are backed up to an `overwritten` directory to be restored later as part of the installation cleanup.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Currently, Modman only supports the Zip archive format. However, work is coming to introduce other popular formats like 7Zip and Rar.
|
||||||
|
|
||||||
|
Further restrictions are imposed on the kinds packaging allowed in the Zip format, with the FOMOD installer format being unsupported.
|
||||||
|
|
||||||
|
Undertaking implementation of a parser and user interface for the FOMOD installer format is not a focus in the development of Modman at this current time, however, contributions are welcome.
|
||||||
|
|
Loading…
Reference in New Issue