Complete architectural rewrite
This commit is contained in:
parent
c63b1dc190
commit
b426b5414c
5
go.mod
5
go.mod
|
@ -2,7 +2,4 @@ module modman
|
|||
|
||||
go 1.19
|
||||
|
||||
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
|
||||
|
|
6
go.sum
6
go.sum
|
@ -1,5 +1,7 @@
|
|||
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w=
|
||||
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/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=
|
||||
|
|
170
main.go
170
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,13 +73,11 @@ var commands = []Command{
|
|||
requiredArguments[0], requiredArguments[1])
|
||||
}
|
||||
|
||||
if game, openGameError := LoadGame(providedArguments[0]); openGameError == nil {
|
||||
if deployError := game.Deploy(providedArguments[1:]); deployError != nil {
|
||||
var deployError = DeployGameMods(providedArguments[0], providedArguments[1:])
|
||||
|
||||
if deployError != nil {
|
||||
return "", deployError
|
||||
}
|
||||
} else {
|
||||
return "", openGameError
|
||||
}
|
||||
|
||||
return "deployed", nil
|
||||
},
|
||||
|
@ -194,43 +94,17 @@ var commands = []Command{
|
|||
return "", fmt.Errorf("expected %s", requiredArguments[0])
|
||||
}
|
||||
|
||||
if game, openGameError := LoadGame(arguments[0]); openGameError == nil {
|
||||
if cleanError := game.Clean(); cleanError != nil {
|
||||
var cleanError = CleanGameMods(arguments[0])
|
||||
|
||||
if cleanError != nil {
|
||||
return "", cleanError
|
||||
}
|
||||
} else {
|
||||
return "", openGameError
|
||||
}
|
||||
|
||||
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() {
|
||||
var argCount = len(os.Args)
|
||||
|
||||
|
|
691
manager.go
691
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"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
for _, filePath := range game.OverwrittenFilePaths {
|
||||
if renameError := os.Rename(filepath.Join(overwriteDirPath, filePath),
|
||||
filepath.Join(game.Path, filePath)); renameError != nil {
|
||||
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() {
|
||||
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]
|
||||
|
||||
return game.saveDeployment()
|
||||
if removeError := os.RemoveAll(overwriteDirPath); removeError != nil {
|
||||
return removeError
|
||||
}
|
||||
|
||||
func (game *Game) Deploy(names []string) error {
|
||||
if cleanError := game.Clean(); cleanError != nil {
|
||||
return cleanError
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
var mod, exists = game.Mods[name]
|
||||
func CreateGame(gameName string, gameDataPath string) error {
|
||||
var gameDataPaths = make(map[string]string)
|
||||
|
||||
if !(exists) {
|
||||
return fmt.Errorf("mod does not exist: %s", name)
|
||||
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
|
||||
}
|
||||
|
||||
var installPath = fmt.Sprintf("%s.%s",
|
||||
filepath.Join(configPath(), game.Name, name), mod.Format)
|
||||
if iniFile, openError := os.Open(gamesIniPath); openError == nil {
|
||||
if (openError != nil) && !(os.IsNotExist(openError)) {
|
||||
return openError
|
||||
}
|
||||
|
||||
switch mod.Format {
|
||||
defer func() {
|
||||
if closeError := iniFile.Close(); closeError != nil {
|
||||
panic(closeError)
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
if openError != nil {
|
||||
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() {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
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 statError
|
||||
return copyError
|
||||
}
|
||||
|
||||
if dirError := os.MkdirAll(installDirPath, archiveFileInfo.Mode()); dirError != nil {
|
||||
return dirError
|
||||
if syncError := stagingFile.Sync(); syncError != nil {
|
||||
stagingFile.Close()
|
||||
|
||||
return syncError
|
||||
}
|
||||
|
||||
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 (copyError != nil) || (syncError != nil) {
|
||||
return fmt.Errorf("failed to install mod")
|
||||
}
|
||||
|
||||
if closeError != nil {
|
||||
if closeError := stagingFile.Close(); closeError != nil {
|
||||
return closeError
|
||||
}
|
||||
} else {
|
||||
return createError
|
||||
}
|
||||
} else {
|
||||
return openError
|
||||
}
|
||||
|
||||
game.Mods[name] = Mod{
|
||||
Format: archiveExtension[1:],
|
||||
Source: archivePath,
|
||||
Version: "",
|
||||
default:
|
||||
return fmt.Errorf("unrecognized archive format: `%s`", archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
return game.saveMods()
|
||||
if walkError := filepath.WalkDir(stageDirPath, func(
|
||||
path string, dirEntry fs.DirEntry, err error) error {
|
||||
|
||||
if dirEntry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (game *Game) Load() error {
|
||||
// Read deployed files from disk.
|
||||
var deployedListPath = filepath.Join(cachePath(), game.Name, "deployed.txt")
|
||||
var relativePath, relativeError = filepath.Rel(stageDirPath, path)
|
||||
|
||||
if file, openError := os.Open(deployedListPath); openError == nil {
|
||||
for scanner := bufio.NewScanner(file); scanner.Scan(); {
|
||||
game.DeployedFilePaths = append(game.DeployedFilePaths, scanner.Text())
|
||||
if relativeError != nil {
|
||||
return relativeError
|
||||
}
|
||||
|
||||
if closeError := file.Close(); closeError != nil {
|
||||
return closeError
|
||||
}
|
||||
} else if !(os.IsNotExist(openError)) {
|
||||
return openError
|
||||
var deployFilePath = filepath.Join(deployDirPath, relativePath)
|
||||
|
||||
if dirError := os.MkdirAll(filepath.Dir(deployFilePath), os.ModePerm); dirError != nil {
|
||||
return dirError
|
||||
}
|
||||
|
||||
// Read overwritten game files from disk.
|
||||
var overwrittenFilesDirPath = filepath.Join(cachePath(), game.Name, "overwritten")
|
||||
if isOverwrittenFilePath := overwrittenFilePaths[relativePath]; !(isOverwrittenFilePath) {
|
||||
var ovewriteFilePath = filepath.Join(overwriteDirPath, relativePath)
|
||||
|
||||
if _, statError := os.Stat(overwrittenFilesDirPath); statError == nil {
|
||||
if walkError := filepath.WalkDir(overwrittenFilesDirPath, func(
|
||||
path string, dirEntry fs.DirEntry, walkError error) error {
|
||||
if _, statError := os.Stat(ovewriteFilePath); statError == nil {
|
||||
if dirError := os.MkdirAll(
|
||||
filepath.Dir(ovewriteFilePath), os.ModePerm); dirError != nil {
|
||||
|
||||
if walkError != nil {
|
||||
return walkError
|
||||
return dirError
|
||||
}
|
||||
|
||||
if !(dirEntry.IsDir()) {
|
||||
game.OverwrittenFilePaths = append(game.OverwrittenFilePaths, path)
|
||||
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
|
||||
}); walkError != nil {
|
||||
if !(os.IsNotExist(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
|
||||
}
|
||||
|
||||
type Mod struct {
|
||||
Format string
|
||||
Source string
|
||||
Version string
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
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() {
|
||||
if entry.Key == "path" {
|
||||
gameNamePaths[entry.Section] = entry.Value
|
||||
var section = parser.Section()
|
||||
|
||||
if (section != gameName) && (entry.Key == "path") {
|
||||
gameDataPaths[section] = entry.Value
|
||||
}
|
||||
}
|
||||
|
||||
if parserError := parser.Err(); parserError != nil {
|
||||
return parserError
|
||||
if parseError := parser.Err(); parseError != nil {
|
||||
return parseError
|
||||
}
|
||||
} 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
|
||||
return saveGames(gameDataPaths)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
for _, filePath := range game.DeployedFilePaths {
|
||||
if _, printError := fmt.Fprintln(writer, filePath); printError != nil {
|
||||
updateError = printError
|
||||
|
||||
break
|
||||
}
|
||||
return "", errGameNotFound
|
||||
}
|
||||
|
||||
if flushError := writer.Flush(); flushError != nil && updateError == nil {
|
||||
updateError = flushError
|
||||
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)
|
||||
|
||||
for name, dataPath := range gameDataPaths {
|
||||
iniBuilder.Section(name)
|
||||
iniBuilder.KeyValue("path", dataPath)
|
||||
}
|
||||
|
||||
if syncError := file.Sync(); syncError != nil && updateError == nil {
|
||||
updateError = syncError
|
||||
if flushError := iniWriter.Flush(); flushError != nil {
|
||||
iniFile.Close()
|
||||
|
||||
return flushError
|
||||
}
|
||||
|
||||
if closeError := file.Close(); closeError != nil && updateError == nil {
|
||||
updateError = closeError
|
||||
if syncError := iniFile.Sync(); syncError != nil {
|
||||
iniFile.Close()
|
||||
|
||||
return syncError
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
52
readme.md
52
readme.md
|
@ -5,3 +5,55 @@ A simple command-line interface for managing and deploying mods to a Elder Scrol
|
|||
## Usage
|
||||
|
||||
A readout of the operations that are supported by Modman can be seen by invoking the `modman` command with no arguments.
|
||||
|
||||
### Getting Started
|
||||
|
||||
Upon initial setup, you will need to create a new game association using `modman create` followed by the name you want to give the game association and the path to its data folder.
|
||||
|
||||
```bash
|
||||
modman create skyrim "~/.steam/steam/steamapps/common/Skyrim Special Edition/Data/"
|
||||
```
|
||||
|
||||
> Example of how to register an installation of Skyrim Special Edition with Modman
|
||||
|
||||
The game name can contain any valid Unicode characters except whitespace when created without double-quotes surrounding it. Meanwhile, the path must be a valid OS-native path to the game data directory.
|
||||
|
||||
### Deploying Mods
|
||||
|
||||
Once a game association has been created, archives of mods can be deployed directly to the game install via `modman deploy` followed by the game association name and then one or more paths to mod archives.
|
||||
|
||||
```bash
|
||||
modman deploy skyrim "~/downloads/Mod1.zip" "~/downloads/Mod2.zip" "~/downloads/Mod3.zip"
|
||||
```
|
||||
|
||||
> Example of how to deploy mods archives to an associated game install.
|
||||
|
||||
Deploy respects the order of the mods list provided to it and will layer them one after the other. In this scenario, `Mod3.zip` will overwrite any files that share the same paths that `Mod2.zip` do, which in turn overwrites any files that share the same paths as `Mod1.zip`.
|
||||
|
||||
Deployments are also transitive, so running `modman deploy` individually each time for `"~/downloads/Mod1.zip"`, `"~/downloads/Mod2.zip"`, and `"~/downloads/Mod3.zip"` will have the same effect as deploying them once in the same command.
|
||||
|
||||
### Cleaning Up
|
||||
|
||||
All mod files managed by Modman can be completely cleaned from an install using `modman clean` followed by the game association name. This will **completely remove** all mod files managed by Modman, so use with care.
|
||||
|
||||
```bash
|
||||
modman clean skyrim
|
||||
```
|
||||
|
||||
> Example of how to clean managed mods from an associated game install.
|
||||
|
||||
Modman does not provide any way to incrementally remove installed mods in the same way that it does with deployment. This is done to avoid corrupting file overwrite precidence, which can become very messy for any mod management tool that compiles all mods into a merged staging directory like Modman.
|
||||
|
||||
## Architecture
|
||||
|
||||
Modman uses a staged deployment system and aims for incremental deployment of mods and atomic, transactional cleanup.
|
||||
|
||||
As mod archives are deployed, their contents are extracted and written to a merged `staged` directory inside the user cache folder, overwriting any files with the same file paths that came before them. After staging, hard links to those files are written into the game directory after any files overwritten by the staged mods are backed up to an `overwritten` directory to be restored later as part of the installation cleanup.
|
||||
|
||||
## Limitations
|
||||
|
||||
Currently, Modman only supports the Zip archive format. However, work is coming to introduce other popular formats like 7Zip and Rar.
|
||||
|
||||
Further restrictions are imposed on the kinds packaging allowed in the Zip format, with the FOMOD installer format being unsupported.
|
||||
|
||||
Undertaking implementation of a parser and user interface for the FOMOD installer format is not a focus in the development of Modman at this current time, however, contributions are welcome.
|
||||
|
|
Loading…
Reference in New Issue