From b426b5414c11af05af94533ea03237a6feff0e18 Mon Sep 17 00:00:00 2001 From: kayomn Date: Sun, 25 Dec 2022 17:31:35 +0000 Subject: [PATCH] Complete architectural rewrite --- go.mod | 5 +- go.sum | 6 +- main.go | 174 ++----------- manager.go | 713 ++++++++++++++++++++++------------------------------- readme.md | 52 ++++ 5 files changed, 373 insertions(+), 577 deletions(-) diff --git a/go.mod b/go.mod index 9afcf31..12b22e1 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,4 @@ module modman go 1.19 -require ( - gopkg.in/yaml.v3 v3.0.1 // indirect - sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221223145344-d68ddf116418 // indirect -) +require sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f diff --git a/go.sum b/go.sum index 3ae7635..2ecabc6 100644 --- a/go.sum +++ b/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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-20221223145344-d68ddf116418/go.mod h1:lniG+VCTpfcWAKKudVYLrS5NIpRx90H3mQklQNn+eK0= +sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f h1:/8n8GAgTrAVQTADtXtHsUOg33jPLVd5srITaU2BplfU= +sauce.pizzawednes.day/kayomn/ini-gusher v0.0.0-20221224215507-01e7b4f4503f/go.mod h1:lniG+VCTpfcWAKKudVYLrS5NIpRx90H3mQklQNn+eK0= diff --git a/main.go b/main.go index c77cf5f..48b209e 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,8 @@ package main import ( - "encoding/json" "fmt" "os" - "strings" - - "gopkg.in/yaml.v3" ) type Command struct { @@ -21,148 +17,54 @@ type CommandAction func([]string, []string) (string, error) var commands = []Command{ { - Name: "install", - 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", + Name: "create", Description: "Registers the data directory path of game for management under the given name", Arguments: []string{"data path", "game name"}, IsVarargs: false, Action: func(requiredArguments []string, providedArguments []string) (string, error) { - if len(providedArguments) < len(requiredArguments) { - return "", fmt.Errorf("expected %s folowed by at least one %ss", + if len(providedArguments) != len(requiredArguments) { + return "", fmt.Errorf("expected %s folowed by %s", requiredArguments[0], requiredArguments[1]) } var dataPath = providedArguments[0] var gameName = providedArguments[1] - if registerGameError := RegisterGame(gameName, dataPath); registerGameError != nil { + if registerGameError := CreateGame(gameName, dataPath); registerGameError != nil { return "", registerGameError } - return fmt.Sprintf("Registered %s at %s", gameName, dataPath), nil + return fmt.Sprintf("Created %s at %s", gameName, dataPath), nil }, }, { Name: "remove", - Description: "Remove one or more mods from modman", - Arguments: []string{"game name", "mod 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"}, + Description: "Cleans and unregisters the given game name from management", + Arguments: []string{"game name"}, IsVarargs: false, Action: func(requiredArguments []string, providedArguments []string) (string, error) { if len(providedArguments) != len(requiredArguments) { - return "", fmt.Errorf("expected %s followed by %s and %s", - requiredArguments[0], requiredArguments[1], requiredArguments[2]) + return "", fmt.Errorf("expected %s", requiredArguments[0]) } - if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil { - if renameError := game.RenameMod(providedArguments[1], providedArguments[2]); renameError != nil { - return "", renameError - } - } else { - return "", openGameError + var dataPath = providedArguments[0] + var gameName = providedArguments[1] + + if registerGameError := RemoveGame(gameName); registerGameError != nil { + return "", registerGameError } - return "renamed", 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 + return fmt.Sprintf("Removed %s at %s", gameName, dataPath), nil }, }, { Name: "deploy", - Description: "Deploy all specified mods in order of listing", - Arguments: []string{"game name", "mod name"}, + Description: "Deploys all specified mod archives in order of listing", + Arguments: []string{"game name", "archive paths"}, IsVarargs: true, Action: func(requiredArguments []string, providedArguments []string) (string, error) { @@ -171,12 +73,10 @@ var commands = []Command{ requiredArguments[0], requiredArguments[1]) } - if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil { - if deployError := game.Deploy(providedArguments[1:]); deployError != nil { - return "", deployError - } - } else { - return "", openGameError + var deployError = DeployGameMods(providedArguments[0], providedArguments[1:]) + + if deployError != nil { + return "", deployError } return "deployed", nil @@ -194,12 +94,10 @@ var commands = []Command{ return "", fmt.Errorf("expected %s", requiredArguments[0]) } - if game, openGameError := LoadGame(arguments[0]); openGameError == nil { - if cleanError := game.Clean(); cleanError != nil { - return "", cleanError - } - } else { - return "", openGameError + var cleanError = CleanGameMods(arguments[0]) + + if cleanError != nil { + return "", cleanError } 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() { var argCount = len(os.Args) diff --git a/manager.go b/manager.go index 0fb2e51..119e51a 100644 --- a/manager.go +++ b/manager.go @@ -3,62 +3,203 @@ package main import ( "archive/zip" "bufio" + "errors" "fmt" "io" "io/fs" "os" "path/filepath" - "strings" "sauce.pizzawednes.day/kayomn/ini-gusher" ) -func (game *Game) Clean() error { - // 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))) { +const appName = "modman" - 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. - for _, filePath := range game.OverwrittenFilePaths { - if renameError := os.Rename(filepath.Join(overwriteDirPath, filePath), - filepath.Join(game.Path, filePath)); renameError != nil { + if walkError := filepath.WalkDir(overwriteDirPath, func( + 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 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 { - if cleanError := game.Clean(); cleanError != nil { - return cleanError +func CreateGame(gameName string, gameDataPath string) error { + var gameDataPaths = make(map[string]string) + + 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 { - var mod, exists = game.Mods[name] - - if !(exists) { - return fmt.Errorf("mod does not exist: %s", name) + if iniFile, openError := os.Open(gamesIniPath); openError == nil { + if (openError != nil) && !(os.IsNotExist(openError)) { + return openError } - var installPath = fmt.Sprintf("%s.%s", - filepath.Join(configPath(), game.Name, name), mod.Format) + defer func() { + 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": - var zipReadCloser, openError = zip.OpenReader(installPath) + var zipReadCloser, openError = zip.OpenReader(archivePath) if openError != nil { return openError @@ -73,9 +214,11 @@ func (game *Game) Deploy(names []string) error { }() 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 } @@ -84,376 +227,143 @@ func (game *Game) Deploy(names []string) error { continue } - // Backup up any pre-existing file before it is overwritten by Zip entry - // extraction. - if _, statError := os.Stat(deployPath); statError == nil { - var backupPath = filepath.Join( - cachePath(), game.Name, "overwritten", zipFile.Name) + var entryReadCloser, openError = zipFile.Open() - if renameError := os.Rename(deployPath, backupPath); renameError != nil { - return renameError - } - - game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, zipFile.Name) - } else if !(os.IsNotExist(statError)) { - return statError + if openError != nil { + return openError } - if file, createError := os.Create(deployPath); createError == nil { - var zipFile, ioError = zipFile.Open() + defer func() { + 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 { - _, ioError = io.Copy(file, zipFile) + if stagingFile, createError := os.Create(stagePath); createError == nil { + if _, copyError := io.Copy(stagingFile, entryReadCloser); copyError != nil { + stagingFile.Sync() + stagingFile.Close() + + return copyError } - if syncError := file.Sync(); (syncError != nil) && (ioError == nil) { - ioError = syncError + if syncError := stagingFile.Sync(); syncError != nil { + stagingFile.Close() + + return 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 { + if closeError := stagingFile.Close(); closeError != nil { return closeError } - + } else { return createError } - - game.DeployedFilePaths = append(game.DeployedFilePaths, zipFile.Name) } 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 { - 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) + if dirEntry.IsDir() { + return nil } - var name = strings.TrimSuffix(archiveName, archiveExtension) + var relativePath, relativeError = filepath.Rel(stageDirPath, path) - if _, exists := game.Mods[name]; exists { - return fmt.Errorf("mod with name already exists: `%s`", name) + if relativeError != nil { + return relativeError } - // Copy archive into installation directory. - if archiveFile, openError := os.Open(archivePath); openError == nil { - defer func() { - if closeError := archiveFile.Close(); closeError != nil { - // Archive file will not have any pending I/O operations nor is it possible to have - // already been closed. - panic(closeError) + var deployFilePath = filepath.Join(deployDirPath, relativePath) + + if dirError := os.MkdirAll(filepath.Dir(deployFilePath), os.ModePerm); dirError != nil { + return dirError + } + + 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 statError != nil { + if renameError := os.Rename(deployFilePath, ovewriteFilePath); renameError != nil { + return renameError + } + } else if !(os.IsNotExist(statError)) { return statError } + } - if dirError := os.MkdirAll(installDirPath, archiveFileInfo.Mode()); dirError != nil { - return dirError + if removeError := os.Remove(deployFilePath); removeError != nil { + if !(os.IsNotExist(removeError)) { + return removeError } + } - if installFile, createError := os.Create(filepath.Join(installDirPath, archiveName)); createError == nil { - var _, copyError = io.Copy(installFile, archiveFile) - var syncError = installFile.Sync() - var closeError = installFile.Close() + if linkError := os.Link(path, deployFilePath); linkError != nil { + return linkError + } - if (copyError != nil) || (syncError != nil) { - return fmt.Errorf("failed to install mod") - } + return nil + }); walkError != nil { + if !(os.IsNotExist(walkError)) { + return walkError + } + } - if closeError != nil { - return closeError - } - } else { - return createError - } - } else { + return nil +} + +func RemoveGame(gameName string) error { + var gameDataPaths = make(map[string]string) + + if iniFile, openError := os.Open(gamesIniPath); openError == nil { + if (openError != nil) && !(os.IsNotExist(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() { - if closeError := file.Close(); closeError != nil { - // File will not have any pending I/O operations nor is it possible to have already - // been closed. + if closeError := iniFile.Close(); closeError != nil { panic(closeError) } }() - var parser = ini.NewParser(file) + var parser = ini.NewParser(iniFile) for entry := parser.Parse(); !(parser.IsEnd()); entry = parser.Parse() { - var mod = game.Mods[entry.Section] + var section = parser.Section() - switch entry.Key { - case "format": - mod.Format = entry.Value - - case "source": - mod.Source = entry.Value - - case "version": - mod.Version = entry.Value + if (section != gameName) && (entry.Key == "path") { + gameDataPaths[section] = entry.Value } - - game.Mods[entry.Section] = mod } - if parserError := parser.Err(); parserError != nil { - return parserError + if parseError := parser.Err(); parseError != nil { + return parseError } } else if !(os.IsNotExist(openError)) { return openError } - return nil + return saveGames(gameDataPaths) } -type Mod struct { - 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 { +func cacheDirPath() string { return fallbackPath(os.UserCacheDir, "cache") } -func configPath() string { +func configDirPath() string { return fallbackPath(os.UserConfigDir, "config") } @@ -469,107 +379,68 @@ func fallbackPath(getFalliblePath func() (string, error), fallbackSubdir string) 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 { - var listPath = filepath.Join(cachePath(), game.Name, "deployed.txt") +func gameDataPath(gameName string) (string, error) { + var gamesFile, openError = os.Open(gamesIniPath) - if len(game.DeployedFilePaths) == 0 { - var truncateError = os.Truncate(listPath, 0) + if (openError != nil) && !(os.IsNotExist(openError)) { + return "", openError + } - if !(os.IsNotExist(truncateError)) { - return truncateError + defer func() { + 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 { - return dirError + if parseError := parser.Err(); parseError != nil { + return "", parseError } - if file, updateError := os.Create(listPath); updateError == nil { - var writer = bufio.NewWriter(file) + return "", errGameNotFound +} - for _, filePath := range game.DeployedFilePaths { - if _, printError := fmt.Fprintln(writer, filePath); printError != nil { - updateError = printError +func saveGames(gameDataPaths map[string]string) error { + if iniFile, createError := os.Create(gamesIniPath); createError == nil { + 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 { - updateError = flushError + if flushError := iniWriter.Flush(); flushError != nil { + iniFile.Close() + + return flushError } - if syncError := file.Sync(); syncError != nil && updateError == nil { - updateError = syncError + if syncError := iniFile.Sync(); syncError != nil { + iniFile.Close() + + return syncError } - if closeError := file.Close(); closeError != nil && updateError == nil { - updateError = closeError - } - - if updateError != nil { - return updateError + if closeError := iniFile.Close(); closeError != nil { + return closeError } } else { - return updateError + return createError } 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 -} diff --git a/readme.md b/readme.md index a2158e5..496a744 100644 --- a/readme.md +++ b/readme.md @@ -5,3 +5,55 @@ A simple command-line interface for managing and deploying mods to a Elder Scrol ## Usage 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.