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=

170
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,13 +73,11 @@ 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 {
if deployError != nil {
return "", deployError return "", deployError
} }
} else {
return "", openGameError
}
return "deployed", nil return "deployed", nil
}, },
@ -194,43 +94,17 @@ 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 {
if cleanError != nil {
return "", cleanError return "", cleanError
} }
} else {
return "", openGameError
}
return "cleaned", nil return "cleaned", nil
}, },
}, },
} }
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))) {
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 removeError
} }
} }
game.DeployedFilePaths = game.DeployedFilePaths[:0] return nil
}); walkError != nil {
if !(os.IsNotExist(walkError)) {
return walkError
}
}
var overwriteDirPath = filepath.Join(cachePath(), game.Name, "overwritten") if removeError := os.RemoveAll(stageDirPath); removeError != nil {
return removeError
}
// Then restore all files overwritten by previously deployment. var overwriteDirPath = filepath.Join(cacheDirPath, "overwritten")
for _, filePath := range game.OverwrittenFilePaths {
if renameError := os.Rename(filepath.Join(overwriteDirPath, filePath), if walkError := filepath.WalkDir(overwriteDirPath, func(
filepath.Join(game.Path, filePath)); renameError != nil { path string, dirEntry fs.DirEntry, err error) error {
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 {
var zipFile, ioError = zipFile.Open()
if ioError == nil {
_, ioError = io.Copy(file, zipFile)
}
if syncError := file.Sync(); (syncError != nil) && (ioError == nil) {
ioError = syncError
}
if closeError := file.Close(); (closeError != nil) && (ioError == 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 createError
}
game.DeployedFilePaths = append(game.DeployedFilePaths, zipFile.Name)
}
default:
return fmt.Errorf("unsupported mod format: %s", mod.Format)
}
}
return game.saveDeployment()
}
type Game struct {
Name string
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)
if _, exists := game.Mods[name]; exists {
return fmt.Errorf("mod with name already exists: `%s`", name)
}
// Copy archive into installation directory.
if archiveFile, openError := os.Open(archivePath); openError == nil {
defer func() { defer func() {
if closeError := archiveFile.Close(); closeError != nil { if closeError := entryReadCloser.Close(); closeError != nil {
// Archive file will not have any pending I/O operations nor is it possible to have // Zip entry read closer will not have any pending I/O operations nor is it
// already been closed. // possible to have already been closed.
panic(closeError) panic(closeError)
} }
}() }()
var archiveFileInfo, statError = archiveFile.Stat() if stagingFile, createError := os.Create(stagePath); createError == nil {
if _, copyError := io.Copy(stagingFile, entryReadCloser); copyError != nil {
stagingFile.Sync()
stagingFile.Close()
if statError != nil { return copyError
return statError
} }
if dirError := os.MkdirAll(installDirPath, archiveFileInfo.Mode()); dirError != nil { if syncError := stagingFile.Sync(); syncError != nil {
return dirError stagingFile.Close()
return syncError
} }
if installFile, createError := os.Create(filepath.Join(installDirPath, archiveName)); createError == nil { if closeError := stagingFile.Close(); closeError != nil {
var _, copyError = io.Copy(installFile, archiveFile)
var syncError = installFile.Sync()
var closeError = installFile.Close()
if (copyError != nil) || (syncError != nil) {
return fmt.Errorf("failed to install mod")
}
if closeError != nil {
return closeError return closeError
} }
} else { } else {
return createError return createError
} }
} else {
return openError
} }
game.Mods[name] = Mod{ default:
Format: archiveExtension[1:], return fmt.Errorf("unrecognized archive format: `%s`", archivePath)
Source: archivePath,
Version: "",
} }
} }
return game.saveMods() if walkError := filepath.WalkDir(stageDirPath, func(
} path string, dirEntry fs.DirEntry, err error) error {
func (game *Game) Load() error { if dirEntry.IsDir() {
// Read deployed files from disk. return nil
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 { var relativePath, relativeError = filepath.Rel(stageDirPath, path)
return closeError
} if relativeError != nil {
} else if !(os.IsNotExist(openError)) { return relativeError
return openError
} }
// Read overwritten game files from disk. var deployFilePath = filepath.Join(deployDirPath, relativePath)
var overwrittenFilesDirPath = filepath.Join(cachePath(), game.Name, "overwritten")
if _, statError := os.Stat(overwrittenFilesDirPath); statError == nil { if dirError := os.MkdirAll(filepath.Dir(deployFilePath), os.ModePerm); dirError != nil {
if walkError := filepath.WalkDir(overwrittenFilesDirPath, func( return dirError
path string, dirEntry fs.DirEntry, walkError error) error {
if walkError != nil {
return walkError
} }
if !(dirEntry.IsDir()) { if isOverwrittenFilePath := overwrittenFilePaths[relativePath]; !(isOverwrittenFilePath) {
game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, path) 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
}
if renameError := os.Rename(deployFilePath, ovewriteFilePath); renameError != nil {
return renameError
}
} else if !(os.IsNotExist(statError)) {
return statError
}
}
if removeError := os.Remove(deployFilePath); removeError != nil {
if !(os.IsNotExist(removeError)) {
return removeError
}
}
if linkError := os.Link(path, deployFilePath); linkError != nil {
return linkError
} }
return nil return nil
}); walkError != nil { }); walkError != nil {
if !(os.IsNotExist(walkError)) {
return walkError 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() {
if closeError := file.Close(); closeError != nil {
// File will not have any pending I/O operations nor is it possible to have already
// been closed.
panic(closeError)
}
}()
var parser = ini.NewParser(file)
for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() {
var mod = game.Mods[entry.Section]
switch entry.Key {
case "format":
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 {
return parserError
}
} else if !(os.IsNotExist(openError)) {
return openError
} }
return nil return nil
} }
type Mod struct { func RemoveGame(gameName string) error {
Format string var gameDataPaths = make(map[string]string)
Source string
Version string
}
func LoadGame(name string) (Game, error) { if iniFile, openError := os.Open(gamesIniPath); openError == nil {
var configPath, configPathError = os.UserConfigDir() if (openError != nil) && !(os.IsNotExist(openError)) {
return openError
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() { defer func() {
if closeError := file.Close(); closeError != nil { if closeError := iniFile.Close(); closeError != nil {
// No way for this to fail.
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() {
if entry.Key == "path" { var section = parser.Section()
gameNamePaths[entry.Section] = entry.Value
if (section != gameName) && (entry.Key == "path") {
gameDataPaths[section] = entry.Value
} }
} }
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
} }
if file, updateError := os.Create(gamesPath); updateError == nil { return saveGames(gameDataPaths)
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 { func cacheDirPath() string {
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.