Complete architectural rewrite

This commit is contained in:
kayomn 2022-12-25 17:31:35 +00:00
parent c63b1dc190
commit b426b5414c
5 changed files with 373 additions and 577 deletions

5
go.mod
View File

@ -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
View File

@ -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
View File

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

View File

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

View File

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