diff --git a/CHANGELOG.md b/CHANGELOG.md index d9327e5..a356c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ This is another pre-release that adds new features. - Support multiple targets: auto-discover the proton prefix and allow arbitrary custom-path targets. +- Add mod deploy rules: skip-backup and write-once. +- Add backups management commands. +- Add `profiles preview` to see the file contents that would be written on + apply. ## v0.5.0 - 2026-03-27 diff --git a/DESIGN.md b/DESIGN.md index 6a0a3a4..e64d179 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -220,11 +220,16 @@ SQLite stores: - mode pages, mode files, mod file versions - profiles and their enabled mod file versions + priority - remap configurations +- deployment rules (`profile_item_skip_backup_patterns`, + `profile_item_write_once_patterns`) - file manifests (planned + installed) - installed file hashes and ownership - backup mappings +- backup management is tracked in `backups` with one row per + `(game_install_id, target_id, relpath)`; rows are removed explicitly via + `games backups delete` or implicitly on unapply restore - operation journal/logs -- override/merge policy data structures (even if unused in v1) +- override/merge policy data structures (even if merge policy unused in v1) - blob references Version schema from day 1. @@ -577,7 +582,143 @@ remap configs, there is no caching of remap results. The planner always derives the final destination path set fresh from the active profile's remap rules at plan time. -## 9. User Overrides / Editable Files +## 9. Deployment Rules + +Deployment rules are per-path pattern lists attached to `profile_items`. Like +remap rules, they are profile-scoped: the same mod in two different profiles +can have different deployment rules. Patterns are evaluated against the final +remapped destination path (post-remap, pre-target-root) — the same coordinate +space as conflict detection. + +Only the winning mod's rules apply to a given path. A losing mod's deployment +rules are irrelevant since its file is not written to disk. + +Deployment rules do not apply to override-owned paths. Overrides always follow +normal backup and deployment behavior. + +### Skip-backup patterns + +A skip-backup pattern suppresses all backup behavior for matched paths: + +- The initial backup of a pre-existing non-tool-owned file is skipped. +- Drift backups (backing up externally modified tool-owned content before + overwriting) are skipped. +- On unapply, matched paths have no backup to restore and are deleted. + +Use this for files that change frequently (e.g. cache files generated by the +game or mod) where accumulating backups is undesirable. The user accepts that +matched paths cannot be restored by modctl on unapply; Steam's "verify +integrity of game files" can be used to recover game-owned files if needed. + +### Write-once patterns + +A write-once pattern deploys a file on first apply and then leaves it +untouched on subsequent applies, preserving any in-game changes made after +the initial deployment: + +- If the file is present on disk and tool-owned, it is skipped (noop) + regardless of drift. An informational warning is emitted if the on-disk + content differs from the installed hash, but no action is taken. +- If the file is missing from disk it is re-deployed as a normal write. + Write-once does not mean "deploy exactly once ever", it means "don't + overwrite changes the game has made." +- The initial backup of a pre-existing non-tool-owned file is still performed, + so unapply can restore the original file correctly. + +Use this for config files that the game modifies at runtime (e.g. settings +changed via an in-game menu) where you want the mod to provide the initial +defaults without overwriting the player's subsequent changes. + +### Combining rules + +Both rules can be applied to the same path. The resulting behavior is: + +- First apply: deploy the file, skip the initial backup. +- Subsequent applies: skip entirely if present on disk. +- Unapply: delete (no backup to restore). + +### Commands +``` +profiles deploys skip-backup add|remove|list|copy +profiles deploys write-once add|remove|list|copy +``` + +When upgrading a mod version via `profiles upgrade`, all deployment rules are +preserved automatically since the profile item is updated in place. Manual +copying via the `copy` subcommand is only needed when moving rules between +two distinct profile items. + +## 10. Backup inspection and management + +The `backups` table records pre-existing files that modctl saved before +overwriting them. Backups are created automatically during apply and restored +automatically during unapply. The backup management commands allow users to +inspect, diff, restore, and delete individual backup entries without running +a full unapply. + +### Scope + +Backups are scoped to `(game_install_id, target_id, relpath)` with no +reference to a profile. This reflects the fact that backups describe +pre-mod on-disk state which is independent of which profile caused the +overwrite. The `games backups` commands operate at the game level and +optionally filter by target. + +### Backup lifecycle + +A backup row is created when apply would overwrite a file that modctl did +not install (i.e. a non-tool-owned file). The blob is content-addressed and +deduplicated; if the same file content is backed up multiple times only one +blob is stored. The backup row is removed when unapply restores the file, or +explicitly via `games backups delete`. + +Deleting a backup row does not immediately remove the blob from disk; run +`gc` to reclaim space. Deleting a backup means modctl cannot restore the +original file at that path on unapply — the file will be deleted instead. + +### Out-of-band restore + +`games backups restore` writes the backup content back to disk immediately +without running a full unapply. This is useful for reverting a single file +to its pre-mod state without touching everything else. It does not update +`installed_files` (the profile's desired state is unchanged), so the next +apply will detect drift and overwrite the restored file again. The command +warns when the active profile is currently applied and suggests adding a +write-once or skip-backup rule if the user wants to preserve the restored +state permanently. + +Restore requires `--force` if the on-disk file has drifted from what modctl +last installed, since this indicates an external modification that the user +should be aware of before overwriting. + +No operation record is created for out-of-band restores. The next apply will +surface the path as drifted, providing an implicit audit trail. + +### Binary detection + +The `view` and `diff` commands detect binary content by scanning the first +8KB of file data for null bytes, matching the heuristic used by Git. Binary +files are refused by default with a clear error message. Pass `--force` to +proceed anyway. + +### Diff commands + +Two diff commands are available: + +**`games backups diff `** shows a unified diff between the backup blob +and the current on-disk content. Direction: backup → on-disk. Shows what has +changed since the backup was taken. If the on-disk file is missing, the +backup content is shown as a full deletion with a warning. + +**`profiles preview `** shows a unified diff between the current +on-disk content and what the active profile's winning mod would write at that +path. Direction: on-disk → incoming. Shows what apply would do to the file. +Requires archive extraction and may be slow for large archives. Errors if no +mod in the active profile provides the path. This command is profile-scoped +and lives under `profiles` rather than `games backups` since it requires +resolving the conflict winner from a specific profile's planned state. + +## 11. User Overrides / Editable Files ### Goal @@ -879,7 +1020,7 @@ For game-scoped import, `overrides` and `override_patch_entries` are added to the ID remapping table. `overrides copy` copies override rows and patch entries in a single transaction, preserving source anchor fields verbatim. -## 10. Mod Incompatibilities +## 12. Mod Incompatibilities ### Purpose @@ -923,7 +1064,7 @@ implicit since mod page IDs are globally unique and carry their own `game_install_id`. For `list` the current game install context is used to scope results. -## 11. Backups strategy +## 13. Backups strategy ### When to back up @@ -943,7 +1084,7 @@ On unapply/rollback: - if user changed file since backup, require explicit choice (or use hash checks) -## 12. Multi-store support +## 14. Multi-store support ### Store integration responsibilities @@ -991,7 +1132,7 @@ absolute path is stored as `root_path` with `origin = 'discovered'`. If a that have never been launched under Proton, or native Linux games, will not have a `proton_prefix` target. -## 13. Extensibility for game-specific integrations +## 15. Extensibility for game-specific integrations ### Integration type @@ -1013,7 +1154,7 @@ Game-specific integrations add/override: This preserves a clean v1 while allowing richer v2. -## 14. Commands +## 16. Commands - `init` initialized the modctl database and storage directories - `auth` @@ -1060,6 +1201,20 @@ This preserves a clean v1 while allowing richer v2. run. - `profiles overrides set|edit|status|unset|list|copy` - manage mod overrides - `profiles overrides patch set|unset|remove|list|preview` - manage structured mod overrides +- `profiles deploys skip-backup add|remove|list|copy` - manage skip-backup patterns + for a mod version within a profile. Patterns are evaluated against the final + remapped destination path. Files matching a skip-backup pattern are never + backed up during apply, including the initial backup of pre-existing files. + Use `copy` to transfer patterns from one mod version to another (e.g. when + manually swapping versions). +- `profiles deploys write-once add|remove|list|copy` - manage write-once patterns + for a mod version within a profile. Patterns are evaluated against the final + remapped destination path. Files matching a write-once pattern are deployed + on first apply and left untouched on subsequent applies unless missing from + disk. Use `copy` to transfer patterns from one mod version to another. +- `profiles preview ` - show a unified diff between the current + on-disk file and what the active profile's winning mod would write at that + path; requires archive extraction which may be slow for large archives - `policy set` (future: merge/manual policy) - `status` (conflicts, drift, missing) - `apply` (top-level) - apply the active profile to the game directory. @@ -1145,6 +1300,17 @@ This preserves a clean v1 while allowing richer v2. - `games targets remove ` - remove a user-defined target. Refuses if any installed files reference the target (unapply first). Cascades to profile items referencing the target. Cannot remove auto-discovered targets. +- `games backups list` - list all backed-up files for the current game, + optionally filtered by target +- `games backups view ` - print the content of a backed-up file to + the terminal; binary files are refused unless `--force` is passed +- `games backups delete ` - delete a backup entry; warns that unapply + will delete rather than restore the file at this path +- `games backups restore ` - restore a backed-up file to disk + immediately without running a full unapply; warns if the active profile + is applied; requires `--force` if the on-disk file has drifted +- `games backups diff ` - show a unified diff between the backup blob + and the current on-disk file Key behavior: - "intent changes" (enable/disable/order) are cheap @@ -1228,7 +1394,7 @@ This check detects added, removed, or swapped mod versions but does not detect priority reordering between mods that conflict on the same path. Run `apply --dry-run` for a precise diff. -## 15. Testing strategy +## 17. Testing strategy ### Unit tests @@ -1275,7 +1441,7 @@ Include in `testdata/`: entries, malformed lines) should be tested at the unit level and included in `testdata/` fixture archives for integration tests -## 16. Operational considerations +## 18. Operational considerations - lock per game during apply to avoid concurrent changes - refuse to operate if game is running (optional v1, but helpful) @@ -1285,7 +1451,7 @@ Include in `testdata/`: of any apply/unapply command, refusing to proceed without `--force` or `--abort` -## 17. Configuration +## 19. Configuration ### File location @@ -1340,7 +1506,7 @@ custom formatting added by hand will not be preserved. if it does not exist; prints a plain-text storage notice when setting `nexus.apikey` -## 18. Nexus Mods integration +## 20. Nexus Mods integration ### Overview @@ -1450,7 +1616,7 @@ The API key is read from config (`nexus.apikey`). If not configured, Nexus linking is silently skipped at import time; commands that require it error with a helpful message. -## 19. Garbage Collection +## 21. Garbage Collection ### Purpose @@ -1508,7 +1674,7 @@ Flags: - `--skip-orphans`: skip on-disk files with no database row (orphans are removed by default) -## 20. Export and Import +## 22. Export and Import ### Purpose diff --git a/cmd/apply.go b/cmd/apply.go index 2782704..dec55ad 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -576,6 +576,8 @@ func printApplyPlan( if op.NeedsBackup { detail = subtle.Render("(backup needed)") countBackup++ + } else if op.SkipBackup { + detail = subtle.Render("(skip-backup)") } winner := op.File.Winner() modInfo := formatModInfo(winner) @@ -594,6 +596,8 @@ func printApplyPlan( if op.NeedsBackup { detail = subtle.Render("(backup needed)") countBackup++ + } else if op.SkipBackup { + detail = subtle.Render("(skip-backup)") } winner := op.File.Winner() modInfo := formatModInfo(winner) diff --git a/cmd/games_backups.go b/cmd/games_backups.go new file mode 100644 index 0000000..16fa3d3 --- /dev/null +++ b/cmd/games_backups.go @@ -0,0 +1,37 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var gamesBackupsCmd = &cobra.Command{ + Use: "backups", + Short: "Inspect and manage backed-up game files", + Long: `Inspect and manage files that modctl backed up before overwriting them. + +Backups are created automatically when modctl overwrites a file it did not +install. They are restored automatically on unapply. These commands let you +inspect, preview, and manage individual backup entries.`, +} + +func init() { + gamesCmd.AddCommand(gamesBackupsCmd) +} diff --git a/cmd/games_backups_delete.go b/cmd/games_backups_delete.go new file mode 100644 index 0000000..4fded10 --- /dev/null +++ b/cmd/games_backups_delete.go @@ -0,0 +1,142 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "path/filepath" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + gamesBackupsDeleteGame string + gamesBackupsDeleteTarget string +) + +var gamesBackupsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a backup entry", + Long: `Delete the backup entry for a path. + +The path is relative to the target root (default: game_dir). Use --target +to delete a backup from a different install target. + +Warning: deleting a backup means modctl cannot restore the original file at +this path on unapply. The file will be deleted instead. The backup blob is +not immediately removed from disk; run 'modctl gc' to reclaim space.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + relpath := filepath.Clean(args[0]) + + if filepath.IsAbs(relpath) { + return fmt.Errorf("path must be relative, got %q", relpath) + } + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if gamesBackupsDeleteGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + gamesBackupsDeleteGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesBackupsDeleteGame) + if err != nil { + return err + } + + targetName := gamesBackupsDeleteTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + fmt.Println(warnStyle.Render(fmt.Sprintf( + " warning: deleting this backup means modctl cannot restore the original file at %q on unapply", + relpath, + ))) + + rows, err := q.DeleteBackupByPath(ctx, dbq.DeleteBackupByPathParams{ + GameInstallID: gi.ID, + TargetID: target.ID, + Relpath: relpath, + }) + if err != nil { + return fmt.Errorf("delete backup: %w", err) + } + if rows == 0 { + return fmt.Errorf("no backup found for %q in target %q", relpath, targetName) + } + + fmt.Printf("Deleted backup for %q (run 'modctl gc' to reclaim disk space)\n", relpath) + return nil + }, +} + +func init() { + gamesBackupsCmd.AddCommand(gamesBackupsDeleteCmd) + + gamesBackupsDeleteCmd.Flags().StringVarP(&gamesBackupsDeleteGame, "game", "g", "", + "Override the currently active game") + gamesBackupsDeleteCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + gamesBackupsDeleteCmd.Flags().StringVarP(&gamesBackupsDeleteTarget, "target", "t", "", + "Install target (default: game_dir)") + gamesBackupsDeleteCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) +} diff --git a/cmd/games_backups_diff.go b/cmd/games_backups_diff.go new file mode 100644 index 0000000..ab15f55 --- /dev/null +++ b/cmd/games_backups_diff.go @@ -0,0 +1,233 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/pmezard/go-difflib/difflib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + gamesBackupsDiffGame string + gamesBackupsDiffTarget string + gamesBackupsDiffForce bool +) + +var gamesBackupsDiffCmd = &cobra.Command{ + Use: "diff ", + Short: "Show a diff between a backup and the current on-disk file", + Long: `Show a unified diff between the backed-up content and the current on-disk file. + +The path is relative to the target root (default: game_dir). Use --target +to diff a backup from a different install target. + +The diff shows what has changed since the backup was taken: + - lines removed from the backup are shown in red + - lines added to the on-disk file are shown in green + +If the on-disk file is missing, the backup content is shown in full as a +deletion. Binary files are detected automatically and refused unless --force +is passed.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + boldStyle := lipgloss.NewStyle().Bold(true) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + addStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + hunkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + + ctx := cmd.Context() + relpath := filepath.Clean(args[0]) + + if filepath.IsAbs(relpath) { + return fmt.Errorf("path must be relative, got %q", relpath) + } + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if gamesBackupsDiffGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + gamesBackupsDiffGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesBackupsDiffGame) + if err != nil { + return err + } + + targetName := gamesBackupsDiffTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + + backup, err := q.GetBackupForGameInstallByPath(ctx, dbq.GetBackupForGameInstallByPathParams{ + GameInstallID: gi.ID, + TargetID: target.ID, + Relpath: relpath, + }) + if err != nil { + return fmt.Errorf("no backup found for %q in target %q", relpath, targetName) + } + + bs := blobstore.Store{ + ArchivesDir: viper.GetString("archives_dir"), + BackupsDir: viper.GetString("backups_dir"), + TmpDir: viper.GetString("tmp_dir"), + } + + blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) + if err != nil { + return fmt.Errorf("resolve backup blob path: %w", err) + } + + backupData, err := os.ReadFile(blobPath) + if err != nil { + return fmt.Errorf("read backup blob: %w", err) + } + + if internal.IsBinaryContent(backupData) && !gamesBackupsDiffForce { + return fmt.Errorf( + "backup for %q appears to be a binary file; pass --force to diff anyway", + relpath, + ) + } + + absPath := filepath.Join(target.RootPath, relpath) + var onDiskData []byte + var missingFromDisk bool + + if _, exists := diskStat(absPath); exists { + onDiskData, err = os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("read on-disk file: %w", err) + } + if internal.IsBinaryContent(onDiskData) && !gamesBackupsDiffForce { + return fmt.Errorf( + "on-disk file %q appears to be binary; pass --force to diff anyway", + relpath, + ) + } + } else { + missingFromDisk = true + fmt.Println(warnStyle.Render(fmt.Sprintf( + " warning: %q is not present on disk; showing backup content as full deletion", + relpath, + ))) + fmt.Println() + } + + fmt.Println(boldStyle.Render(fmt.Sprintf("Backup diff for %q:", relpath))) + fmt.Println() + + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(backupData)), + B: difflib.SplitLines(string(onDiskData)), + FromFile: relpath + " (backup)", + ToFile: relpath + " (on disk)", + Context: 3, + }) + if err != nil { + return fmt.Errorf("generate diff: %w", err) + } + + if diff == "" && !missingFromDisk { + fmt.Println(subtleStyle.Render(" no differences (on-disk file matches backup)")) + return nil + } + + for _, line := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + fmt.Println(boldStyle.Render(line)) + case strings.HasPrefix(line, "@@"): + fmt.Println(hunkStyle.Render(line)) + case strings.HasPrefix(line, "+"): + fmt.Println(addStyle.Render(line)) + case strings.HasPrefix(line, "-"): + fmt.Println(removeStyle.Render(line)) + default: + fmt.Println(subtleStyle.Render(line)) + } + } + + return nil + }, +} + +func init() { + gamesBackupsCmd.AddCommand(gamesBackupsDiffCmd) + + gamesBackupsDiffCmd.Flags().StringVarP(&gamesBackupsDiffGame, "game", "g", "", + "Override the currently active game") + gamesBackupsDiffCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + gamesBackupsDiffCmd.Flags().StringVarP(&gamesBackupsDiffTarget, "target", "t", "", + "Install target (default: game_dir)") + gamesBackupsDiffCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) + + gamesBackupsDiffCmd.Flags().BoolVar(&gamesBackupsDiffForce, "force", false, + "Diff binary files without refusing") +} diff --git a/cmd/games_backups_list.go b/cmd/games_backups_list.go new file mode 100644 index 0000000..26d97f4 --- /dev/null +++ b/cmd/games_backups_list.go @@ -0,0 +1,135 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + gamesBackupsListGame string + gamesBackupsListTarget string +) + +var gamesBackupsListCmd = &cobra.Command{ + Use: "list", + Short: "List backed-up files for the current game", + Long: `List all files that modctl has backed up for the current game. + +Backups are created automatically before overwriting a file that modctl did +not install. They are restored automatically on unapply.`, + Args: cobra.ExactArgs(0), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + + ctx := cmd.Context() + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if gamesBackupsListGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + gamesBackupsListGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesBackupsListGame) + if err != nil { + return err + } + + backups, err := q.ListBackupsForGameInstall(ctx, dbq.ListBackupsForGameInstallParams{ + GameInstallID: gi.ID, + TargetName: gamesBackupsListTarget, + }) + if err != nil { + return fmt.Errorf("list backups: %w", err) + } + + if len(backups) == 0 { + fmt.Println(subtleStyle.Render(" no backups found")) + return nil + } + + rows := [][]string{} + for _, b := range backups { + opInfo := "" + if b.OperationID.Valid { + opInfo = fmt.Sprintf("%s #%d", b.OperationType.String, b.OperationID.Int64) + } + rows = append(rows, []string{ + fmt.Sprintf(" %s ", b.TargetName), + fmt.Sprintf(" %s ", b.Relpath), + fmt.Sprintf(" %s ", formatBytes(b.SizeBytes)), + fmt.Sprintf(" %s ", b.CreatedAt), + fmt.Sprintf(" %s ", opInfo), + }) + } + + t := table.New(). + Headers(" Target ", " Path ", " Size ", " Backed Up At ", " Operation "). + Rows(rows...) + fmt.Println(t) + return nil + }, +} + +func init() { + gamesBackupsCmd.AddCommand(gamesBackupsListCmd) + + gamesBackupsListCmd.Flags().StringVarP(&gamesBackupsListGame, "game", "g", "", + "Override the currently active game") + gamesBackupsListCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + gamesBackupsListCmd.Flags().StringVarP(&gamesBackupsListTarget, "target", "t", "", + "Filter by install target (default: all targets)") + gamesBackupsListCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) +} diff --git a/cmd/games_backups_restore.go b/cmd/games_backups_restore.go new file mode 100644 index 0000000..136d9a8 --- /dev/null +++ b/cmd/games_backups_restore.go @@ -0,0 +1,238 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + gamesBackupsRestoreGame string + gamesBackupsRestoreTarget string + gamesBackupsRestoreForce bool +) + +var gamesBackupsRestoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Restore a backed-up file to disk immediately", + Long: `Restore a backed-up file to disk immediately without running unapply. + +The path is relative to the target root (default: game_dir). Use --target +to restore a backup from a different install target. + +This is useful when you want to revert a single file to its pre-mod state +without unapplying everything. If the active profile is currently applied, +running apply again will overwrite this path. Consider adding a write-once +or skip-backup rule if you want to preserve this behavior permanently. + +If the file currently on disk differs from what modctl last installed (drift), +the command warns and requires --force to proceed.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + + ctx := cmd.Context() + relpath := filepath.Clean(args[0]) + + if filepath.IsAbs(relpath) { + return fmt.Errorf("path must be relative, got %q", relpath) + } + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if gamesBackupsRestoreGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + gamesBackupsRestoreGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesBackupsRestoreGame) + if err != nil { + return err + } + + targetName := gamesBackupsRestoreTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + + backup, err := q.GetBackupForGameInstallByPath(ctx, dbq.GetBackupForGameInstallByPathParams{ + GameInstallID: gi.ID, + TargetID: target.ID, + Relpath: relpath, + }) + if err != nil { + return fmt.Errorf("no backup found for %q in target %q", relpath, targetName) + } + + bs := blobstore.Store{ + ArchivesDir: viper.GetString("archives_dir"), + BackupsDir: viper.GetString("backups_dir"), + TmpDir: viper.GetString("tmp_dir"), + } + + blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) + if err != nil { + return fmt.Errorf("resolve backup blob path: %w", err) + } + + absPath := filepath.Join(target.RootPath, relpath) + + // Check for drift: if the file is on disk and tool-owned, verify + // it matches what modctl installed before restoring over it. + installedFile, err := q.GetInstalledFileByPath(ctx, dbq.GetInstalledFileByPathParams{ + GameInstallID: gi.ID, + TargetID: target.ID, + Relpath: relpath, + }) + if err == nil { + // File is tool-owned - check for drift + if _, exists := diskStat(absPath); exists { + onDiskHash, hashErr := hashFile(absPath) + if hashErr == nil && onDiskHash != installedFile.ContentSha256 { + if !gamesBackupsRestoreForce { + return fmt.Errorf( + "file %q has been modified since modctl installed it (drift detected); pass --force to restore anyway", + relpath, + ) + } + fmt.Println(warnStyle.Render(fmt.Sprintf( + " warning: %q has been modified since modctl installed it, restoring backup anyway", + relpath, + ))) + } + } + } + + // Warn if profile is currently applied + appliedState, err := q.GetGameInstallAppliedState(ctx, gi.ID) + if err == nil && appliedState.AppliedProfileID.Valid { + fmt.Println(warnStyle.Render( + " warning: the active profile is currently applied; running apply again will overwrite this path\n" + + " consider adding a write-once or skip-backup rule if you want to preserve this behavior permanently", + )) + } + + // Write backup content to disk + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("create parent directories: %w", err) + } + if err := copyFileSimple(blobPath, absPath); err != nil { + return fmt.Errorf("restore backup: %w", err) + } + + fmt.Printf("Restored backup for %q\n", relpath) + return nil + }, +} + +func init() { + gamesBackupsCmd.AddCommand(gamesBackupsRestoreCmd) + + gamesBackupsRestoreCmd.Flags().StringVarP(&gamesBackupsRestoreGame, "game", "g", "", + "Override the currently active game") + gamesBackupsRestoreCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + gamesBackupsRestoreCmd.Flags().StringVarP(&gamesBackupsRestoreTarget, "target", "t", "", + "Install target (default: game_dir)") + gamesBackupsRestoreCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) + + gamesBackupsRestoreCmd.Flags().BoolVar(&gamesBackupsRestoreForce, "force", false, + "Restore even if the on-disk file has drifted from what modctl installed") +} + +// copyFileSimple copies src to dst, creating or truncating dst. +// TODO: we have a couple of other similar functions floating around we can +// +// probably consolidate +func copyFileSimple(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create destination: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy: %w", err) + } + return out.Sync() +} + +// TODO copied from the internal/planner package, let's either export it from +// +// there or copy it somewhere else and export it and use it in both +// places +func diskStat(path string) (os.FileInfo, bool) { + info, err := os.Stat(path) + if err != nil { + return nil, false + } + return info, true +} diff --git a/cmd/games_backups_view.go b/cmd/games_backups_view.go new file mode 100644 index 0000000..6a41910 --- /dev/null +++ b/cmd/games_backups_view.go @@ -0,0 +1,159 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + gamesBackupsViewGame string + gamesBackupsViewTarget string + gamesBackupsViewForce bool +) + +var gamesBackupsViewCmd = &cobra.Command{ + Use: "view ", + Short: "Print the content of a backed-up file", + Long: `Print the content of a backed-up file to the terminal. + +The path is relative to the target root (default: game_dir). Use --target +to view a backup from a different install target. + +Binary files are detected automatically and refused unless --force is passed.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + relpath := filepath.Clean(args[0]) + + if filepath.IsAbs(relpath) { + return fmt.Errorf("path must be relative, got %q", relpath) + } + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if gamesBackupsViewGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + gamesBackupsViewGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesBackupsViewGame) + if err != nil { + return err + } + + targetName := gamesBackupsViewTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + + backup, err := q.GetBackupForGameInstallByPath(ctx, dbq.GetBackupForGameInstallByPathParams{ + GameInstallID: gi.ID, + TargetID: target.ID, + Relpath: relpath, + }) + if err != nil { + return fmt.Errorf("no backup found for %q in target %q", relpath, targetName) + } + + bs := blobstore.Store{ + ArchivesDir: viper.GetString("archives_dir"), + BackupsDir: viper.GetString("backups_dir"), + TmpDir: viper.GetString("tmp_dir"), + } + + blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) + if err != nil { + return fmt.Errorf("resolve backup blob path: %w", err) + } + + data, err := os.ReadFile(blobPath) + if err != nil { + return fmt.Errorf("read backup blob: %w", err) + } + + if internal.IsBinaryContent(data) && !gamesBackupsViewForce { + return fmt.Errorf( + "backup for %q appears to be a binary file; pass --force to print anyway", + relpath, + ) + } + + fmt.Print(string(data)) + return nil + }, +} + +func init() { + gamesBackupsCmd.AddCommand(gamesBackupsViewCmd) + + gamesBackupsViewCmd.Flags().StringVarP(&gamesBackupsViewGame, "game", "g", "", + "Override the currently active game") + gamesBackupsViewCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + gamesBackupsViewCmd.Flags().StringVarP(&gamesBackupsViewTarget, "target", "t", "", + "Install target (default: game_dir)") + gamesBackupsViewCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) + gamesBackupsViewCmd.Flags().BoolVar(&gamesBackupsViewForce, "force", false, + "Print binary files without refusing") +} diff --git a/cmd/profiles_deploys.go b/cmd/profiles_deploys.go new file mode 100644 index 0000000..c8b364f --- /dev/null +++ b/cmd/profiles_deploys.go @@ -0,0 +1,32 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var profilesDeploysCmd = &cobra.Command{ + Use: "deploys", + Short: "Manage deployment rules for mod versions", +} + +func init() { + profilesCmd.AddCommand(profilesDeploysCmd) +} diff --git a/cmd/profiles_deploys_skipBackup.go b/cmd/profiles_deploys_skipBackup.go new file mode 100644 index 0000000..e8afbbc --- /dev/null +++ b/cmd/profiles_deploys_skipBackup.go @@ -0,0 +1,42 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var profilesDeploysSkipBackupCmd = &cobra.Command{ + Use: "skip-backup", + Short: "Manage skip-backup patterns", + Long: `Manage skip-backup patterns for mod versions in a profile. + +Files matching a skip-backup pattern (evaluated against the final remapped +destination path) are never backed up during apply. This includes both the +initial backup of a pre-existing file and any subsequent drift backups. + +Use this for files that change frequently (e.g. cache files) where +accumulating backups is undesirable. Note that matched paths cannot be +restored by modctl on unapply; Steam's "verify integrity of game files" +can be used to recover game-owned files if needed.`, +} + +func init() { + profilesDeploysCmd.AddCommand(profilesDeploysSkipBackupCmd) +} diff --git a/cmd/profiles_deploys_skipBackup_add.go b/cmd/profiles_deploys_skipBackup_add.go new file mode 100644 index 0000000..31328c5 --- /dev/null +++ b/cmd/profiles_deploys_skipBackup_add.go @@ -0,0 +1,154 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "errors" + "fmt" + "strconv" + + "github.com/mattn/go-sqlite3" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysSkipBackupAddGame string + profilesDeploysSkipBackupAddProfile string +) + +var profilesDeploysSkipBackupAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a skip-backup pattern for a mod version", + Long: `Add a skip-backup pattern for a mod version in a profile. + +Files matching the pattern (evaluated against the final remapped destination +path) will never be backed up during apply, even if drift is detected or a +pre-existing file would otherwise be saved to the backup store. + +The user accepts that matched paths cannot be restored by modctl on unapply; +Steam's "verify integrity of game files" can be used to recover game-owned +files if needed. + +Examples: + modctl profiles deploys skip-backup add 42 "*.cache" + modctl profiles deploys skip-backup add 42 "Cache/*"`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pattern := args[1] + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysSkipBackupAddGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysSkipBackupAddGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysSkipBackupAddGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysSkipBackupAddProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + if err := q.AddSkipBackupPattern(ctx, dbq.AddSkipBackupPatternParams{ + ProfileItemID: itemID, + Pattern: pattern, + }); err != nil { + if isUniqueConstraintError(err) { + return fmt.Errorf("pattern %q already exists for version %d in profile %q", pattern, mfv.ID, p.Name) + } + return fmt.Errorf("add skip-backup pattern: %w", err) + } + + fmt.Printf("Added skip-backup pattern %q for version %d in profile %q\n", pattern, mfv.ID, p.Name) + return nil + }, +} + +func init() { + profilesDeploysSkipBackupCmd.AddCommand(profilesDeploysSkipBackupAddCmd) + + profilesDeploysSkipBackupAddCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupAddGame, "game", "g", "", + "Override the currently active game") + profilesDeploysSkipBackupAddCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + profilesDeploysSkipBackupAddCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupAddProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysSkipBackupAddCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} + +func isUniqueConstraintError(err error) bool { + var se sqlite3.Error + return errors.As(err, &se) && + se.Code == sqlite3.ErrConstraint && + se.ExtendedCode == sqlite3.ErrConstraintUnique +} diff --git a/cmd/profiles_deploys_skipBackup_copy.go b/cmd/profiles_deploys_skipBackup_copy.go new file mode 100644 index 0000000..65637e8 --- /dev/null +++ b/cmd/profiles_deploys_skipBackup_copy.go @@ -0,0 +1,133 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysSkipBackupCopyGame string + profilesDeploysSkipBackupCopyProfile string +) + +var profilesDeploysSkipBackupCopyCmd = &cobra.Command{ + Use: "copy ", + Short: "Copy skip-backup patterns from one mod version to another in a profile", + Long: `Copy skip-backup patterns from one mod version to another within the same profile. + +If the destination already has skip-backup patterns they will be replaced. +If the source has no skip-backup patterns this is a no-op. + +This is useful when manually swapping mod versions and you want to preserve +the skip-backup configuration from the old version.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 2 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.ModFileVersionIDs(cmd, toComplete) + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysSkipBackupCopyGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysSkipBackupCopyGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysSkipBackupCopyGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysSkipBackupCopyProfile) + if err != nil { + return err + } + + mfvSrc, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + mfvDst, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[1]) + if err != nil { + return err + } + + srcItemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfvSrc.ID) + if err != nil { + return fmt.Errorf("source: %w", err) + } + + dstItemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfvDst.ID) + if err != nil { + return fmt.Errorf("destination: %w", err) + } + + return internal.CopySkipBackupPatterns(ctx, db, q, srcItemID, dstItemID, mfvSrc.ID, mfvDst.ID, p.Name) + }, +} + +func init() { + profilesDeploysSkipBackupCmd.AddCommand(profilesDeploysSkipBackupCopyCmd) + + profilesDeploysSkipBackupCopyCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupCopyGame, "game", "g", "", + "Override the currently active game") + profilesDeploysSkipBackupCopyCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + profilesDeploysSkipBackupCopyCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupCopyProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysSkipBackupCopyCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_skipBackup_list.go b/cmd/profiles_deploys_skipBackup_list.go new file mode 100644 index 0000000..47d628b --- /dev/null +++ b/cmd/profiles_deploys_skipBackup_list.go @@ -0,0 +1,138 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysSkipBackupListGame string + profilesDeploysSkipBackupListProfile string +) + +var profilesDeploysSkipBackupListCmd = &cobra.Command{ + Use: "list ", + Short: "List skip-backup patterns for a mod version", + Long: `List all skip-backup patterns for a mod version in a profile.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + boldStyle := lipgloss.NewStyle().Bold(true) + + ctx := cmd.Context() + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysSkipBackupListGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysSkipBackupListGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysSkipBackupListGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysSkipBackupListProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + patterns, err := q.ListSkipBackupPatterns(ctx, itemID) + if err != nil { + return fmt.Errorf("list skip-backup patterns: %w", err) + } + + if len(patterns) == 0 { + fmt.Println(subtleStyle.Render(fmt.Sprintf(" no skip-backup patterns for version %d in profile %q", mfv.ID, p.Name))) + return nil + } + + fmt.Println(boldStyle.Render(fmt.Sprintf("Skip-backup patterns for version %d in profile %q:", mfv.ID, p.Name))) + for _, row := range patterns { + fmt.Printf(" %s\n", row.Pattern) + } + return nil + }, +} + +func init() { + profilesDeploysSkipBackupCmd.AddCommand(profilesDeploysSkipBackupListCmd) + + profilesDeploysSkipBackupListCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupListGame, "game", "g", "", + "Override the currently active game") + profilesDeploysSkipBackupListCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + profilesDeploysSkipBackupListCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupListProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysSkipBackupListCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_skipBackup_remove.go b/cmd/profiles_deploys_skipBackup_remove.go new file mode 100644 index 0000000..7358b14 --- /dev/null +++ b/cmd/profiles_deploys_skipBackup_remove.go @@ -0,0 +1,136 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysSkipBackupRemoveGame string + profilesDeploysSkipBackupRemoveProfile string +) + +var profilesDeploysSkipBackupRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a skip-backup pattern for a mod version", + Long: `Remove a skip-backup pattern for a mod version in a profile. + +Use 'skip-backup list' to see current patterns.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pattern := args[1] + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysSkipBackupRemoveGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysSkipBackupRemoveGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysSkipBackupRemoveGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysSkipBackupRemoveProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + rows, err := q.RemoveSkipBackupPattern(ctx, dbq.RemoveSkipBackupPatternParams{ + ProfileItemID: itemID, + Pattern: pattern, + }) + if err != nil { + return fmt.Errorf("remove skip-backup pattern: %w", err) + } + if rows == 0 { + return fmt.Errorf("pattern %q not found for version %d in profile %q", pattern, mfv.ID, p.Name) + } + + fmt.Printf("Removed skip-backup pattern %q for version %d in profile %q\n", pattern, mfv.ID, p.Name) + return nil + }, +} + +func init() { + profilesDeploysSkipBackupCmd.AddCommand(profilesDeploysSkipBackupRemoveCmd) + + profilesDeploysSkipBackupRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupRemoveGame, "game", "g", "", + "Override the currently active game") + profilesDeploysSkipBackupRemoveCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + profilesDeploysSkipBackupRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupRemoveProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysSkipBackupRemoveCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_writeOnce.go b/cmd/profiles_deploys_writeOnce.go new file mode 100644 index 0000000..ec7cc32 --- /dev/null +++ b/cmd/profiles_deploys_writeOnce.go @@ -0,0 +1,46 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var profilesDeploysWriteOnceCmd = &cobra.Command{ + Use: "write-once", + Short: "Manage write-once patterns", + Long: `Manage write-once patterns for mod versions in a profile. + +Files matching a write-once pattern (evaluated against the final remapped +destination path) are deployed on first apply and then left untouched on +subsequent applies, preserving any in-game changes made after the initial +deployment. + +If a matched file is missing from disk it will be re-deployed. The initial +backup of any pre-existing non-tool-owned file is still performed, so +unapply can restore the original file correctly. + +Use this for config files that the game modifies at runtime (e.g. settings +changed via an in-game menu) where you want the mod to provide the initial +defaults without overwriting the player's subsequent changes.`, +} + +func init() { + profilesDeploysCmd.AddCommand(profilesDeploysWriteOnceCmd) +} diff --git a/cmd/profiles_deploys_writeOnce_add.go b/cmd/profiles_deploys_writeOnce_add.go new file mode 100644 index 0000000..9957169 --- /dev/null +++ b/cmd/profiles_deploys_writeOnce_add.go @@ -0,0 +1,143 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysWriteOnceAddGame string + profilesDeploysWriteOnceAddProfile string +) + +var profilesDeploysWriteOnceAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a write-once pattern for a mod version", + Long: `Add a write-once pattern for a mod version in a profile. + +Files matching the pattern (evaluated against the final remapped destination +path) are deployed on first apply and then left untouched on subsequent +applies, preserving any in-game changes made after the initial deployment. + +If a matched file is missing from disk it will be re-deployed regardless. +If the underlying mod file changes, profiles status will surface a warning. + +Examples: + modctl profiles deploys write-once add 42 "settings.ini" + modctl profiles deploys write-once add 42 "Config/*.cfg"`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pattern := args[1] + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysWriteOnceAddGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysWriteOnceAddGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysWriteOnceAddGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysWriteOnceAddProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + if err := q.AddWriteOncePattern(ctx, dbq.AddWriteOncePatternParams{ + ProfileItemID: itemID, + Pattern: pattern, + }); err != nil { + if isUniqueConstraintError(err) { + return fmt.Errorf("pattern %q already exists for version %d in profile %q", pattern, mfv.ID, p.Name) + } + return fmt.Errorf("add write-once pattern: %w", err) + } + + fmt.Printf("Added write-once pattern %q for version %d in profile %q\n", pattern, mfv.ID, p.Name) + return nil + }, +} + +func init() { + profilesDeploysWriteOnceCmd.AddCommand(profilesDeploysWriteOnceAddCmd) + + profilesDeploysWriteOnceAddCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceAddGame, "game", "g", "", + "Override the currently active game") + profilesDeploysWriteOnceAddCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + profilesDeploysWriteOnceAddCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceAddProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysWriteOnceAddCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_writeOnce_copy.go b/cmd/profiles_deploys_writeOnce_copy.go new file mode 100644 index 0000000..4c95f33 --- /dev/null +++ b/cmd/profiles_deploys_writeOnce_copy.go @@ -0,0 +1,133 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysWriteOnceCopyGame string + profilesDeploysWriteOnceCopyProfile string +) + +var profilesDeploysWriteOnceCopyCmd = &cobra.Command{ + Use: "copy ", + Short: "Copy write-once patterns from one mod version to another in a profile", + Long: `Copy write-once patterns from one mod version to another within the same profile. + +If the destination already has write-once patterns they will be replaced. +If the source has no write-once patterns this is a no-op. + +This is useful when manually swapping mod versions and you want to preserve +the write-once configuration from the old version.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 2 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.ModFileVersionIDs(cmd, toComplete) + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysWriteOnceCopyGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysWriteOnceCopyGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysWriteOnceCopyGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysWriteOnceCopyProfile) + if err != nil { + return err + } + + mfvSrc, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + mfvDst, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[1]) + if err != nil { + return err + } + + srcItemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfvSrc.ID) + if err != nil { + return fmt.Errorf("source: %w", err) + } + + dstItemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfvDst.ID) + if err != nil { + return fmt.Errorf("destination: %w", err) + } + + return internal.CopyWriteOncePatterns(ctx, db, q, srcItemID, dstItemID, mfvSrc.ID, mfvDst.ID, p.Name) + }, +} + +func init() { + profilesDeploysWriteOnceCmd.AddCommand(profilesDeploysWriteOnceCopyCmd) + + profilesDeploysWriteOnceCopyCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceCopyGame, "game", "g", "", + "Override the currently active game") + profilesDeploysWriteOnceCopyCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + profilesDeploysWriteOnceCopyCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceCopyProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysWriteOnceCopyCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_writeOnce_list.go b/cmd/profiles_deploys_writeOnce_list.go new file mode 100644 index 0000000..2ae5bc0 --- /dev/null +++ b/cmd/profiles_deploys_writeOnce_list.go @@ -0,0 +1,138 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysWriteOnceListGame string + profilesDeploysWriteOnceListProfile string +) + +var profilesDeploysWriteOnceListCmd = &cobra.Command{ + Use: "list ", + Short: "List write-once patterns for a mod version", + Long: `List all write-once patterns for a mod version in a profile.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + boldStyle := lipgloss.NewStyle().Bold(true) + + ctx := cmd.Context() + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysWriteOnceListGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysWriteOnceListGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysWriteOnceListGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysWriteOnceListProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + patterns, err := q.ListWriteOncePatterns(ctx, itemID) + if err != nil { + return fmt.Errorf("list write-once patterns: %w", err) + } + + if len(patterns) == 0 { + fmt.Println(subtleStyle.Render(fmt.Sprintf(" no write-once patterns for version %d in profile %q", mfv.ID, p.Name))) + return nil + } + + fmt.Println(boldStyle.Render(fmt.Sprintf("Write-once patterns for version %d in profile %q:", mfv.ID, p.Name))) + for _, row := range patterns { + fmt.Printf(" %s\n", row.Pattern) + } + return nil + }, +} + +func init() { + profilesDeploysWriteOnceCmd.AddCommand(profilesDeploysWriteOnceListCmd) + + profilesDeploysWriteOnceListCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceListGame, "game", "g", "", + "Override the currently active game") + profilesDeploysWriteOnceListCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + profilesDeploysWriteOnceListCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceListProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysWriteOnceListCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_deploys_writeOnce_remove.go b/cmd/profiles_deploys_writeOnce_remove.go new file mode 100644 index 0000000..dd27a22 --- /dev/null +++ b/cmd/profiles_deploys_writeOnce_remove.go @@ -0,0 +1,136 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +var ( + profilesDeploysWriteOnceRemoveGame string + profilesDeploysWriteOnceRemoveProfile string +) + +var profilesDeploysWriteOnceRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a write-once pattern for a mod version", + Long: `Remove a write-once pattern for a mod version in a profile. + +Use 'write-once list' to see current patterns.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ModFileVersionIDs(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pattern := args[1] + + err := internal.EnsureDBExists() + if err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + err = internal.MigrateDB(ctx, db) + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesDeploysWriteOnceRemoveGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesDeploysWriteOnceRemoveGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesDeploysWriteOnceRemoveGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesDeploysWriteOnceRemoveProfile) + if err != nil { + return err + } + + mfv, err := internal.ResolveModFileVersionArg(ctx, q, gi, args[0]) + if err != nil { + return err + } + + itemID, err := internal.ResolveProfileItemByVersion(ctx, &p, q, mfv.ID) + if err != nil { + return err + } + + rows, err := q.RemoveWriteOncePattern(ctx, dbq.RemoveWriteOncePatternParams{ + ProfileItemID: itemID, + Pattern: pattern, + }) + if err != nil { + return fmt.Errorf("remove write-once pattern: %w", err) + } + if rows == 0 { + return fmt.Errorf("pattern %q not found for version %d in profile %q", pattern, mfv.ID, p.Name) + } + + fmt.Printf("Removed write-once pattern %q for version %d in profile %q\n", pattern, mfv.ID, p.Name) + return nil + }, +} + +func init() { + profilesDeploysWriteOnceCmd.AddCommand(profilesDeploysWriteOnceRemoveCmd) + + profilesDeploysWriteOnceRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceRemoveGame, "game", "g", "", + "Override the currently active game") + profilesDeploysWriteOnceRemoveCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + profilesDeploysWriteOnceRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceRemoveProfile, "profile", "p", "", + "Override the currently active profile") + profilesDeploysWriteOnceRemoveCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) +} diff --git a/cmd/profiles_preview.go b/cmd/profiles_preview.go new file mode 100644 index 0000000..292243e --- /dev/null +++ b/cmd/profiles_preview.go @@ -0,0 +1,274 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/extractor" + "github.com/mfinelli/modctl/internal/planner" + "github.com/mfinelli/modctl/internal/state" + "github.com/pmezard/go-difflib/difflib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + profilesPreviewGame string + profilesPreviewProfile string + profilesPreviewTarget string + profilesPreviewForce bool +) + +var profilesPreviewCmd = &cobra.Command{ + Use: "preview ", + Short: "Show a diff between the on-disk file and what apply would write", + Long: `Show a unified diff between the current on-disk file and what the active +profile would write at that path if apply were run. + +The path is relative to the target root (default: game_dir). Use --target +to preview a path in a different install target. + +The diff shows what apply would change: + - lines removed from the on-disk file are shown in red + - lines that apply would write are shown in green + +Errors if no mod in the active profile provides this path. Note that this +command requires archive extraction which may be slow for large archives. + +Binary files are detected automatically and refused unless --force is passed.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + boldStyle := lipgloss.NewStyle().Bold(true) + addStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + hunkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + + ctx := cmd.Context() + relpath := filepath.Clean(args[0]) + + if filepath.IsAbs(relpath) { + return fmt.Errorf("path must be relative, got %q", relpath) + } + + if err := internal.EnsureDBExists(); err != nil { + return err + } + db, err := internal.SetupDB() + if err != nil { + return fmt.Errorf("error setting up database: %w", err) + } + defer db.Close() + if err := internal.MigrateDB(ctx, db); err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + q := dbq.New(db) + + if profilesPreviewGame == "" { + active, err := state.LoadActive() + if err != nil { + return fmt.Errorf("load active selection: %w", err) + } + if active.ActiveGameInstallID == 0 { + return fmt.Errorf("no active game selected; run `modctl games set-active ...` or pass --game") + } + profilesPreviewGame = strconv.FormatInt(active.ActiveGameInstallID, 10) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, profilesPreviewGame) + if err != nil { + return err + } + + p, err := argresolver.ResolveProfileArg(ctx, q, &gi, profilesPreviewProfile) + if err != nil { + return err + } + + targetName := profilesPreviewTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + + // Build apply plan to find the winner for this path + plan, err := planner.BuildApplyPlan(ctx, q, gi.ID, p.ID, target, false) + if err != nil { + var uninvErr *planner.UninventoriedArchiveError + if errors.As(err, &uninvErr) { + return fmt.Errorf("%w\nrun 'modctl mods scan-inventory' to fix", uninvErr) + } + return fmt.Errorf("build apply plan: %w", err) + } + + // Find the op for this specific path + var winnerOp *planner.PlanOp + for i := range plan.Ops { + if plan.Ops[i].DestPath == relpath { + op := plan.Ops[i] + winnerOp = &op + break + } + } + + if winnerOp == nil || winnerOp.File == nil || len(winnerOp.File.Conflicts) == 0 { + return fmt.Errorf("no mod in the active profile provides %q", relpath) + } + + winner := winnerOp.File.Winner() + + bs := blobstore.Store{ + ArchivesDir: viper.GetString("archives_dir"), + BackupsDir: viper.GetString("backups_dir"), + OverridesDir: viper.GetString("overrides_dir"), + TmpDir: viper.GetString("tmp_dir"), + } + + ex := extractor.Extractor{ + BsdtarPath: viper.GetString("bsdtar"), + BlobStore: bs, + StagingDir: viper.GetString("tmp_dir"), + } + + stagingDir, err := ex.ExtractArchive(ctx, winner.Entry.ArchiveSha256) + if err != nil { + return fmt.Errorf("extract archive: %w", err) + } + defer os.RemoveAll(stagingDir) + + stagedPath := filepath.Join(stagingDir, winner.Entry.SourcePath) + incomingData, err := os.ReadFile(stagedPath) + if err != nil { + return fmt.Errorf("read staged file: %w", err) + } + + if internal.IsBinaryContent(incomingData) && !profilesPreviewForce { + return fmt.Errorf( + "incoming file for %q appears to be binary; pass --force to diff anyway", + relpath, + ) + } + + absPath := filepath.Join(target.RootPath, relpath) + var onDiskData []byte + if _, exists := diskStat(absPath); exists { + onDiskData, err = os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("read on-disk file: %w", err) + } + if internal.IsBinaryContent(onDiskData) && !profilesPreviewForce { + return fmt.Errorf( + "on-disk file %q appears to be binary; pass --force to diff anyway", + relpath, + ) + } + } + + modInfo := winner.ModPageName + if winner.VersionString != "" { + modInfo += " " + winner.VersionString + } + + fmt.Println(boldStyle.Render(fmt.Sprintf( + "Apply preview for %q in profile %q:", relpath, p.Name, + ))) + fmt.Println(subtleStyle.Render(fmt.Sprintf(" winner: %s", modInfo))) + fmt.Println() + + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(onDiskData)), + B: difflib.SplitLines(string(incomingData)), + FromFile: relpath + " (on disk)", + ToFile: relpath + " (incoming)", + Context: 3, + }) + if err != nil { + return fmt.Errorf("generate diff: %w", err) + } + + if diff == "" { + fmt.Println(subtleStyle.Render(" no differences (on-disk file matches what apply would write)")) + return nil + } + + for _, line := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + fmt.Println(boldStyle.Render(line)) + case strings.HasPrefix(line, "@@"): + fmt.Println(hunkStyle.Render(line)) + case strings.HasPrefix(line, "+"): + fmt.Println(addStyle.Render(line)) + case strings.HasPrefix(line, "-"): + fmt.Println(removeStyle.Render(line)) + default: + fmt.Println(subtleStyle.Render(line)) + } + } + + return nil + }, +} + +func init() { + profilesCmd.AddCommand(profilesPreviewCmd) + + profilesPreviewCmd.Flags().StringVarP(&profilesPreviewGame, "game", "g", "", + "Override the currently active game") + profilesPreviewCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + profilesPreviewCmd.Flags().StringVarP(&profilesPreviewProfile, "profile", "p", "", + "Override the currently active profile") + profilesPreviewCmd.RegisterFlagCompletionFunc("profile", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ProfileNames(cmd, toComplete) + }) + profilesPreviewCmd.Flags().StringVarP(&profilesPreviewTarget, "target", "t", "", + "Install target (default: game_dir)") + profilesPreviewCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) + + profilesPreviewCmd.Flags().BoolVar(&profilesPreviewForce, "force", false, + "Diff binary files without refusing") +} diff --git a/cmd/profiles_upgrade.go b/cmd/profiles_upgrade.go index eccf497..8716326 100644 --- a/cmd/profiles_upgrade.go +++ b/cmd/profiles_upgrade.go @@ -42,7 +42,8 @@ var profilesUpgradeCmd = &cobra.Command{ Use: "upgrade ", Short: "Swap a mod version in a profile for a newer one", Long: `Swap the mod file version currently in a profile for a newer one, -preserving the existing priority slot, enabled state, and remap rules. +preserving the existing priority slot, enabled state, remap rules, and +deploy rules. Without --to, modctl picks the most recently imported version of the same mod file that is not already in the profile. diff --git a/docs/05_guides/05_overrides.md b/docs/05_guides/05_overrides.md index 4a43e2b..3472f26 100644 --- a/docs/05_guides/05_overrides.md +++ b/docs/05_guides/05_overrides.md @@ -217,5 +217,5 @@ change in the base file. --- -Next up is the [Configuration reference](../configuration), which covers all -available configuration keys and their defaults. +Next is the [Deploy rules](../deploy-rules) guide, which covers how top stop +modctl from backing up or overwriting certain mod-shipped files. diff --git a/docs/05_guides/06_deploy-rules.md b/docs/05_guides/06_deploy-rules.md new file mode 100644 index 0000000..768e003 --- /dev/null +++ b/docs/05_guides/06_deploy-rules.md @@ -0,0 +1,120 @@ +# Deployment rules + +Deployment rules let you change how modctl handles specific files during apply, +on a per-mod basis. There are two kinds: skip-backup patterns and write-once +patterns. + +Like remap rules, deployment rules are profile-scoped: the same mod version +can have different rules in different profiles. Patterns are evaluated against +the final remapped destination path (the path that will actually be written +to disk) so if you have remap rules configured, write your patterns against +the remapped result, not the raw archive path. + +Only the winning mod's rules apply to a given path. A mod that loses a +conflict has no effect on how its file is handled. + +## Skip-backup patterns + +By default, modctl backs up any file before overwriting it: the original +game-owned file is saved before the first apply, and if the file is later +modified externally (for example by a game update), that modified version is +saved before being overwritten on the next apply. This is what makes unapply +safe; there is always something to restore. + +For some files this behavior is more annoying than useful. Mods that ship +cache files, log files, or other frequently-regenerated content will cause +backups to accumulate on every apply run, consuming disk space and adding +noise to the operation log. + +A skip-backup pattern tells modctl never to back up files matching that path +for this mod in this profile: +```bash +modctl profiles deploys skip-backup add "My Mod" "*.cache" +modctl profiles deploys skip-backup add "My Mod" "Cache/*" +``` + +**What changes with skip-backup active:** + +- The initial backup of a pre-existing game-owned file is skipped. +- Drift backups (saving an externally modified file before overwriting it) + are skipped. +- On unapply, matched paths have no backup to restore and are simply deleted. + +The tradeoff is that if a game update writes something meaningful to a +skip-backup path, modctl will overwrite it silently on the next apply and +there will be nothing to restore on unapply. For genuinely ephemeral files +like caches this is fine. For anything the game writes intentionally you +should use write-once instead. If you need to recover a file covered by a +skip-backup pattern, Steam's "verify integrity of game files" will restore it. + +## Write-once patterns + +Some mods ship config files with sensible defaults that the game then modifies +as you change settings in-game. Without any rules, modctl would overwrite +those in-game changes on every apply run, backing up the modified version +each time, but still replacing it with the mod's original. + +A write-once pattern tells modctl to deploy the file on the first apply and +then leave it alone on subsequent applies: +```bash +modctl profiles deploys write-once add "My Mod" "settings.ini" +modctl profiles deploys write-once add "My Mod" "Config/*.cfg" +``` + +**What write-once does:** + +- First apply: the file is deployed normally. If a game-owned file was + already at that path it is backed up first, so unapply can restore it. +- Subsequent applies: if the file is present on disk and tool-owned, it is + skipped entirely regardless of whether its content has changed. +- Missing file: if the file has been deleted from disk it is re-deployed. + Write-once means "don't overwrite changes the game has made", not "deploy + exactly once and never again". + +When drift detection is enabled (the default), modctl will emit an +informational warning if a write-once file has been modified since it was +deployed, so you are aware the file differs from the mod's version. No +action is taken. + +## Combining both rules + +You can apply both rules to the same path. The result is: + +- First apply: the file is deployed, the initial backup is skipped. +- Subsequent applies: the file is skipped if present on disk. +- Unapply: the file is deleted (no backup to restore). + +This combination makes sense for files that are both ephemeral and +game-modified, something modctl should seed once and then ignore entirely. + +## Viewing and managing rules +```bash +# List current patterns +modctl profiles deploys skip-backup list "My Mod" +modctl profiles deploys write-once list "My Mod" + +# Remove a pattern +modctl profiles deploys skip-backup remove "My Mod" "*.cache" +modctl profiles deploys write-once remove "My Mod" "settings.ini" +``` + +## Copying rules between mod versions + +When you manually swap a mod version between two distinct profile items you +can copy deployment rules across: +```bash +modctl profiles deploys skip-backup copy "My Mod v1.0" "My Mod v1.1" +modctl profiles deploys write-once copy "My Mod v1.0" "My Mod v1.1" +``` + +If the destination already has patterns of that kind they will be replaced. +If the source has none this is a no-op. + +Note that when you use `modctl profiles upgrade` to upgrade a mod, deployment +rules are preserved automatically — no copying is needed. The copy commands +are only useful when moving rules between two distinct profile items manually. + +--- + +Next up is the [Configuration reference](../configuration), which covers all +available configuration keys and their defaults. diff --git a/docs/05_guides/06_configuration.md b/docs/05_guides/07_configuration.md similarity index 100% rename from docs/05_guides/06_configuration.md rename to docs/05_guides/07_configuration.md diff --git a/docs/05_guides/07_support.md b/docs/05_guides/08_support.md similarity index 100% rename from docs/05_guides/07_support.md rename to docs/05_guides/08_support.md diff --git a/docs/06_commands/02_profiles.md b/docs/06_commands/02_profiles.md index 1419982..7f2bb8a 100644 --- a/docs/06_commands/02_profiles.md +++ b/docs/06_commands/02_profiles.md @@ -116,7 +116,7 @@ modctl profiles remove "Appearance Menu Mod" ### profiles upgrade Swap the mod file version currently in a profile for a newer one, preserving -the existing priority slot, enabled state, and remap rules. +the existing priority slot, enabled state, remap rules, and deployment rules Without `--to`, modctl picks the most recently imported version of the same mod file that is not already in the profile. With `--to`, the specified @@ -142,6 +142,27 @@ modctl profiles enable "Appearance Menu Mod" modctl profiles disable "Appearance Menu Mod" ``` +### profiles preview + +Show a unified diff between the current on-disk file and what the active +profile's winning mod would write at that path if apply were run. Useful +for understanding exactly what apply would change at a specific path before +deciding whether to add a write-once or skip-backup rule. +```bash +modctl profiles preview "settings/game.ini" +``` + +Note: this command requires archive extraction which may be slow for large +archives. + +Binary files are detected automatically and refused unless `--force` is +passed. + +| Flag | Description | +|-------------------|--------------------------------------------------| +| `--target ` | Install target (default: `game_dir`) | +| `--force` | Diff binary files without refusing | + ## Priority order ### profiles order move @@ -383,3 +404,78 @@ extraction and may be slow for large archives. ```bash modctl profiles overrides patch preview settings.ini ``` + +## Deployment rules + +For a full explanation of deployment rules see +[Deployment rules](../../guides/deploy-rules). + +### profiles deploys skip-backup list + +List all skip-backup patterns for a mod version in the active profile. +Patterns are evaluated against the final remapped destination path. +```bash +modctl profiles deploys skip-backup list "My Mod" +``` + +### profiles deploys skip-backup add + +Add a skip-backup pattern for a mod version in the active profile. Files +matching the pattern are never backed up during apply, including the initial +backup of any pre-existing game-owned file. On unapply, matched paths are +deleted rather than restored. +```bash +modctl profiles deploys skip-backup add "My Mod" "*.cache" +modctl profiles deploys skip-backup add "My Mod" "Cache/*" +``` + +### profiles deploys skip-backup remove + +Remove a skip-backup pattern. Use `skip-backup list` to see current patterns. +```bash +modctl profiles deploys skip-backup remove "My Mod" "*.cache" +``` + +### profiles deploys skip-backup copy + +Copy skip-backup patterns from one mod version to another within the same +profile. Existing patterns on the destination are replaced. Useful when +manually swapping mod versions. +```bash +modctl profiles deploys skip-backup copy "My Mod v1.0" "My Mod v1.1" +``` + +### profiles deploys write-once list + +List all write-once patterns for a mod version in the active profile. +Patterns are evaluated against the final remapped destination path. +```bash +modctl profiles deploys write-once list "My Mod" +``` + +### profiles deploys write-once add + +Add a write-once pattern for a mod version in the active profile. Files +matching the pattern are deployed on first apply and left untouched on +subsequent applies, preserving any in-game changes. If a matched file is +missing from disk it is re-deployed. +```bash +modctl profiles deploys write-once add "My Mod" "settings.ini" +modctl profiles deploys write-once add "My Mod" "Config/*.cfg" +``` + +### profiles deploys write-once remove + +Remove a write-once pattern. Use `write-once list` to see current patterns. +```bash +modctl profiles deploys write-once remove "My Mod" "settings.ini" +``` + +### profiles deploys write-once copy + +Copy write-once patterns from one mod version to another within the same +profile. Existing patterns on the destination are replaced. Useful when +manually swapping mod versions. +```bash +modctl profiles deploys write-once copy "My Mod v1.0" "My Mod v1.1" +``` diff --git a/docs/06_commands/04_games-stores.md b/docs/06_commands/04_games-stores.md index e715b11..656f687 100644 --- a/docs/06_commands/04_games-stores.md +++ b/docs/06_commands/04_games-stores.md @@ -132,6 +132,105 @@ targets (`game_dir`, `proton_prefix`) cannot be removed. modctl games targets remove saves ``` +## Backups + +modctl automatically backs up game-owned files before overwriting them during +apply. These backups are restored automatically on unapply. The backup commands +let you inspect and manage individual backup entries without running a full +unapply. + +Backups are scoped to the game install rather than a specific profile, since +they describe the pre-mod state of the filesystem regardless of which profile +caused the overwrite. + +### games backups list + +List all backed-up files for the current game. Shows the target, path, size, +when the backup was taken, and which operation created it. +```bash +modctl games backups list +``` + +Pass `--target` to filter to a specific install target: +```bash +modctl games backups list --target proton_prefix +``` + +| Flag | Description | +|-------------------|-------------------------------------------------| +| `--target ` | Filter by install target (default: all targets) | + +### games backups view + +Print the content of a backed-up file to the terminal. The path is relative +to the target root. +```bash +modctl games backups view "settings/game.ini" +``` + +Binary files are detected automatically and refused. Pass `--force` to print +them anyway. + +| Flag | Description | +|-------------------|--------------------------------------| +| `--target ` | Install target (default: `game_dir`) | +| `--force` | Print binary files without refusing | + +### games backups delete + +Delete a backup entry. The blob is not immediately removed from disk; run +`modctl gc` to reclaim space. +```bash +modctl games backups delete "settings/game.ini" +``` + +Note that deleting a backup means modctl cannot restore the original file at +this path on unapply; the file will be deleted instead of restored. + +| Flag | Description | +|-------------------|--------------------------------------| +| `--target ` | Install target (default: `game_dir`) | + +### games backups restore + +Restore a backed-up file to disk immediately without running a full unapply. +Useful for reverting a single file to its pre-mod state while leaving +everything else in place. +```bash +modctl games backups restore "settings/game.ini" +``` + +If the active profile is currently applied, modctl warns that running apply +again will overwrite this path. If you want the restored file to be +preserved across future applies, add a write-once or skip-backup rule for +this path. + +If the on-disk file has drifted from what modctl last installed, `--force` +is required to proceed. + +| Flag | Description | +|-------------------|-------------------------------------------------------------------------| +| `--target ` | Install target (default: `game_dir`) | +| `--force` | Restore even if the on-disk file has drifted from what modctl installed | + +### games backups diff + +Show a unified diff between the backed-up content and the current on-disk +file. Useful for understanding what has changed since the backup was taken, +for example to decide whether to restore the backup or add a deploy rule. +```bash +modctl games backups diff "settings/game.ini" +``` + +If the on-disk file is missing, the backup content is shown as a full +deletion with a warning. Binary files are detected automatically and refused +unless `--force` is passed. + +| Flag | Description | +|-------------------|------------------------------------- | +| `--target ` | Install target (default: `game_dir`) | +| `--force` | Diff binary files without refusing | + --- ## stores diff --git a/internal/binary.go b/internal/binary.go new file mode 100644 index 0000000..46f1960 --- /dev/null +++ b/internal/binary.go @@ -0,0 +1,35 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package internal + +// IsBinaryContent reports whether data appears to be binary by scanning for +// null bytes in the first 8KB, matching the heuristic used by Git and other +// tools. +func IsBinaryContent(data []byte) bool { + check := data + if len(check) > 8192 { + check = check[:8192] + } + for _, b := range check { + if b == 0 { + return true + } + } + return false +} diff --git a/internal/deployrules.go b/internal/deployrules.go new file mode 100644 index 0000000..013b61a --- /dev/null +++ b/internal/deployrules.go @@ -0,0 +1,178 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package internal + +import ( + "context" + "database/sql" + "fmt" + + "github.com/mfinelli/modctl/dbq" +) + +// CopyDeployRules copies skip-backup and write-once patterns from one profile +// item to another. If the destination already has patterns of either kind they +// are replaced. If the source has no patterns for a given kind that kind is a +// no-op. Returns the counts of patterns copied for each kind. +func CopyDeployRules(ctx context.Context, db *sql.DB, q *dbq.Queries, srcItemID, dstItemID int64, srcVersionID, dstVersionID int64, profileName string) error { + skipBackupPatterns, err := q.ListSkipBackupPatterns(ctx, srcItemID) + if err != nil { + return fmt.Errorf("list source skip-backup patterns: %w", err) + } + + writeOncePatterns, err := q.ListWriteOncePatterns(ctx, srcItemID) + if err != nil { + return fmt.Errorf("list source write-once patterns: %w", err) + } + + if len(skipBackupPatterns) == 0 && len(writeOncePatterns) == 0 { + fmt.Printf("Version %d in profile %q has no deploy rules to copy\n", srcVersionID, profileName) + return nil + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + qtx := q.WithTx(tx) + + // Replace destination skip-backup patterns + if err := qtx.DeleteAllSkipBackupPatterns(ctx, dstItemID); err != nil { + return fmt.Errorf("clear dst skip-backup patterns: %w", err) + } + for _, p := range skipBackupPatterns { + if err := qtx.AddSkipBackupPattern(ctx, dbq.AddSkipBackupPatternParams{ + ProfileItemID: dstItemID, + Pattern: p.Pattern, + }); err != nil { + return fmt.Errorf("copy skip-backup pattern %q: %w", p.Pattern, err) + } + } + + // Replace destination write-once patterns + if err := qtx.DeleteAllWriteOncePatterns(ctx, dstItemID); err != nil { + return fmt.Errorf("clear dst write-once patterns: %w", err) + } + for _, p := range writeOncePatterns { + if err := qtx.AddWriteOncePattern(ctx, dbq.AddWriteOncePatternParams{ + ProfileItemID: dstItemID, + Pattern: p.Pattern, + }); err != nil { + return fmt.Errorf("copy write-once pattern %q: %w", p.Pattern, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + if len(skipBackupPatterns) > 0 { + fmt.Printf("Copied %d skip-backup pattern(s) from version %d to version %d in profile %q\n", + len(skipBackupPatterns), srcVersionID, dstVersionID, profileName) + } + if len(writeOncePatterns) > 0 { + fmt.Printf("Copied %d write-once pattern(s) from version %d to version %d in profile %q\n", + len(writeOncePatterns), srcVersionID, dstVersionID, profileName) + } + + return nil +} + +// CopySkipBackupPatterns copies skip-backup patterns from one profile item to +// another within the same profile. If the destination already has patterns +// they are replaced. If the source has no patterns this is a no-op. +func CopySkipBackupPatterns(ctx context.Context, db *sql.DB, q *dbq.Queries, srcItemID, dstItemID int64, srcVersionID, dstVersionID int64, profileName string) error { + patterns, err := q.ListSkipBackupPatterns(ctx, srcItemID) + if err != nil { + return fmt.Errorf("list source skip-backup patterns: %w", err) + } + if len(patterns) == 0 { + fmt.Printf("Version %d in profile %q has no skip-backup patterns to copy\n", srcVersionID, profileName) + return nil + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + qtx := q.WithTx(tx) + + if err := qtx.DeleteAllSkipBackupPatterns(ctx, dstItemID); err != nil { + return fmt.Errorf("clear dst skip-backup patterns: %w", err) + } + for _, p := range patterns { + if err := qtx.AddSkipBackupPattern(ctx, dbq.AddSkipBackupPatternParams{ + ProfileItemID: dstItemID, + Pattern: p.Pattern, + }); err != nil { + return fmt.Errorf("copy skip-backup pattern %q: %w", p.Pattern, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + fmt.Printf("Copied %d skip-backup pattern(s) from version %d to version %d in profile %q\n", + len(patterns), srcVersionID, dstVersionID, profileName) + return nil +} + +// CopyWriteOncePatterns copies write-once patterns from one profile item to +// another within the same profile. If the destination already has patterns +// they are replaced. If the source has no patterns this is a no-op. +func CopyWriteOncePatterns(ctx context.Context, db *sql.DB, q *dbq.Queries, srcItemID, dstItemID int64, srcVersionID, dstVersionID int64, profileName string) error { + patterns, err := q.ListWriteOncePatterns(ctx, srcItemID) + if err != nil { + return fmt.Errorf("list source write-once patterns: %w", err) + } + if len(patterns) == 0 { + fmt.Printf("Version %d in profile %q has no write-once patterns to copy\n", srcVersionID, profileName) + return nil + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + qtx := q.WithTx(tx) + + if err := qtx.DeleteAllWriteOncePatterns(ctx, dstItemID); err != nil { + return fmt.Errorf("clear dst write-once patterns: %w", err) + } + for _, p := range patterns { + if err := qtx.AddWriteOncePattern(ctx, dbq.AddWriteOncePatternParams{ + ProfileItemID: dstItemID, + Pattern: p.Pattern, + }); err != nil { + return fmt.Errorf("copy write-once pattern %q: %w", p.Pattern, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + fmt.Printf("Copied %d write-once pattern(s) from version %d to version %d in profile %q\n", + len(patterns), srcVersionID, dstVersionID, profileName) + return nil +} diff --git a/internal/exporter/game.go b/internal/exporter/game.go index 4743be3..0afe1ca 100644 --- a/internal/exporter/game.go +++ b/internal/exporter/game.go @@ -276,6 +276,16 @@ func buildGameScopedDB( return "", "", 0, 0, 0, fmt.Errorf("export profile items: %w", err) } + if err := exportSkipBackupPatterns(ctx, q, sq, gi.ID); err != nil { + os.Remove(tmpPath) + return "", "", 0, 0, 0, fmt.Errorf("export skip-backup patterns: %w", err) + } + + if err := exportWriteOncePatterns(ctx, q, sq, gi.ID); err != nil { + os.Remove(tmpPath) + return "", "", 0, 0, 0, fmt.Errorf("export write-once patterns: %w", err) + } + if err := exportProfilePathPolicies(ctx, q, sq, gi.ID); err != nil { os.Remove(tmpPath) return "", "", 0, 0, 0, fmt.Errorf("export profile path policies: %w", err) @@ -526,3 +536,29 @@ func exportOverrides(ctx context.Context, src, dst *dbq.Queries, gameInstallID i } return overrideCount, nil } + +func exportSkipBackupPatterns(ctx context.Context, src, dst *dbq.Queries, gameInstallID int64) error { + rows, err := src.ExportGetSkipBackupPatternsForGameInstall(ctx, gameInstallID) + if err != nil { + return fmt.Errorf("get skip-backup patterns: %w", err) + } + for _, row := range rows { + if err := dst.ExportInsertSkipBackupPattern(ctx, dbq.ExportInsertSkipBackupPatternParams(row)); err != nil { + return fmt.Errorf("insert skip-backup pattern %d: %w", row.ID, err) + } + } + return nil +} + +func exportWriteOncePatterns(ctx context.Context, src, dst *dbq.Queries, gameInstallID int64) error { + rows, err := src.ExportGetWriteOncePatternsForGameInstall(ctx, gameInstallID) + if err != nil { + return fmt.Errorf("get write-once patterns: %w", err) + } + for _, row := range rows { + if err := dst.ExportInsertWriteOncePattern(ctx, dbq.ExportInsertWriteOncePatternParams(row)); err != nil { + return fmt.Errorf("insert write-once pattern %d: %w", row.ID, err) + } + } + return nil +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 815353a..337f48e 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -107,6 +107,13 @@ type PlanOp struct { OverrideBaseArchiveSha256 sql.NullString // OverrideBaseRawPath is the path inside the base archive for patch ops. OverrideBaseRawPath sql.NullString + + // SkipBackup is true when a skip-backup pattern matched this path. + // Informational only; used for dry-run display. + SkipBackup bool + // WriteOnce is true when a write-once pattern matched this path. + // Informational only; used for dry-run display. + WriteOnce bool } // Plan is the full computed desired state for a profile apply or unapply. @@ -161,6 +168,26 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI return Plan{}, fmt.Errorf("load profile items: %w", err) } + // Load skip-backup and write-once patterns for all items in this profile, + // keyed by profile_item_id for fast lookup during op building. + skipBackupRows, err := q.GetSkipBackupPatternsForProfile(ctx, profileID) + if err != nil { + return Plan{}, fmt.Errorf("load skip-backup patterns: %w", err) + } + skipBackupPatterns := make(map[int64][]string, len(skipBackupRows)) + for _, row := range skipBackupRows { + skipBackupPatterns[row.ProfileItemID] = append(skipBackupPatterns[row.ProfileItemID], row.Pattern) + } + + writeOnceRows, err := q.GetWriteOncePatternsForProfile(ctx, profileID) + if err != nil { + return Plan{}, fmt.Errorf("load write-once patterns: %w", err) + } + writeOncePatterns := make(map[int64][]string, len(writeOnceRows)) + for _, row := range writeOnceRows { + writeOncePatterns[row.ProfileItemID] = append(writeOncePatterns[row.ProfileItemID], row.Pattern) + } + // winner map: destPath -> index into plan.Files winners := make(map[string]int) @@ -343,6 +370,14 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI op.OverrideBaseRawPath = oi.baseRawPath } + // Resolve deploy rules for the winning item (overrides are not + // subject to skip-backup or write-once rules, those are properties + // of the mod item, not the override layer) + isSkipBackup := !hasOverride && matchesAny(skipBackupPatterns[pf.ProfileItemID], pf.DestPath) + isWriteOnce := !hasOverride && matchesAny(writeOncePatterns[pf.ProfileItemID], pf.DestPath) + op.SkipBackup = isSkipBackup + op.WriteOnce = isWriteOnce + existingInstall, isInstalled := installed[pf.DestPath] _, existsOnDisk := diskStat(absPath) @@ -360,8 +395,8 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI // Fall through to plain overwrite if we can't hash op.Kind = PlanOpOverwrite } else if onDiskHash == existingInstall.ContentSha256 && - // File is already correct - noop. existingInstall.OwnerOverrideID.Int64 == oi.id { + // File is already correct: noop op.Kind = PlanOpNoop } else { op.Kind = PlanOpOverwrite @@ -369,6 +404,23 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI } else if hasOverride && skipRecheck { // --no-recheck with override: always reapply op.Kind = PlanOpOverwrite + } else if isWriteOnce { + // Write-once: leave the file untouched regardless of drift. + // Still hash-check so we can surface an informational warning + // if the file has been modified (e.g. by the game), but never + // act on it. + op.Kind = PlanOpNoop + if !skipRecheck { + onDiskHash, err := hashFile(absPath) + if err != nil { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("recheck: could not hash %q: %v", pf.DestPath, err)) + } else if onDiskHash != existingInstall.ContentSha256 { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("write-once: %q has been modified since last deploy (write-once rule active, leaving as-is)", + pf.DestPath)) + } + } } else if !skipRecheck { // Normal mod-owned file recheck onDiskHash, err := hashFile(absPath) @@ -381,25 +433,31 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI op.Kind = PlanOpNoop } else if onDiskHash != existingInstall.ContentSha256 && existingInstall.OwnerModFileVersionID.Int64 == pf.Winner().ModFileVersionID { - // Same owner but content drifted externally - back up new content + // Same owner but content drifted: back up unless skip-backup op.Kind = PlanOpOverwrite - op.NeedsBackup = true - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("drift: %q was modified externally (game update?), will back up current content before overwriting", - pf.DestPath)) + if !isSkipBackup { + op.NeedsBackup = true + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("drift: %q was modified externally (game update?), will back up current content before overwriting", + pf.DestPath)) + } else { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("drift: %q was modified externally, overwriting without backup (skip-backup rule active)", + pf.DestPath)) + } } else { - // Different winner - plain overwrite, no backup needed since - // tool owns the file + // Different winner: plain overwrite, no backup needed + // since tool owns file op.Kind = PlanOpOverwrite } } else { - // Tool owns it and it's on disk - normal overwrite, no backup + // --no-recheck: tool owns it and it's on disk, normal overwrite, no backup op.Kind = PlanOpOverwrite } case isInstalled && !existsOnDisk: // Tool thought it owned it but it's gone - drift warning, treat - // as a fresh write + // as a fresh write. (write-once re-deploys missing files) op.Kind = PlanOpWrite plan.Warnings = append(plan.Warnings, fmt.Sprintf("drift: %q was installed but is missing from disk", @@ -407,8 +465,11 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI case !isInstalled && existsOnDisk: // Pre-existing file not owned by the tool - backup before writing + // unless skip-backup is active op.Kind = PlanOpWrite - op.NeedsBackup = true + if !isSkipBackup { + op.NeedsBackup = true + } default: // Not installed, not on disk - clean write @@ -442,7 +503,7 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI _, existsOnDisk := diskStat(absPath) if !existsOnDisk { - // Already gone - just clean up the DB record, no disk op needed. + // Already gone: just clean up the DB record, no disk op needed // We still emit a Remove so apply cleans up installed_files plan.Warnings = append(plan.Warnings, fmt.Sprintf("drift: %q was installed by mod_file_version %d but is already missing from disk", @@ -544,3 +605,18 @@ func diskStat(path string) (os.FileInfo, bool) { } return info, true } + +// matchesAny reports whether path matches any of the given glob patterns. +// Malformed patterns are silently skipped. +func matchesAny(patterns []string, path string) bool { + for _, pattern := range patterns { + matched, err := filepath.Match(pattern, path) + if err != nil { + continue + } + if matched { + return true + } + } + return false +} diff --git a/internal/restore/game.go b/internal/restore/game.go index 4ff14dc..f7c5a6b 100644 --- a/internal/restore/game.go +++ b/internal/restore/game.go @@ -39,6 +39,7 @@ type idRemap struct { remapConfigs map[int64]int64 profiles map[int64]int64 overrides map[int64]int64 + profileItems map[int64]int64 } // Game imports a game-scoped bundle into an existing database. @@ -155,6 +156,7 @@ func Game( remapConfigs: make(map[int64]int64), profiles: make(map[int64]int64), overrides: make(map[int64]int64), + profileItems: make(map[int64]int64), } // Insert store (reuse existing if present) @@ -208,6 +210,14 @@ func Game( return res, fmt.Errorf("import profile items: %w", err) } + if err := importSkipBackupPatterns(ctx, q, bq, oldGameInstallID, remap); err != nil { + return res, fmt.Errorf("import skip-backup patterns: %w", err) + } + + if err := importWriteOncePatterns(ctx, q, bq, oldGameInstallID, remap); err != nil { + return res, fmt.Errorf("import write-once patterns: %w", err) + } + // Profile path policies if err := importProfilePathPolicies(ctx, q, bq, oldGameInstallID, remap); err != nil { return res, fmt.Errorf("import profile path policies: %w", err) @@ -490,7 +500,7 @@ func importProfileItems(ctx context.Context, dst, src *dbq.Queries, oldGameInsta } newRemapConfigID = sql.NullInt64{Int64: newID, Valid: true} } - if _, err := dst.ImportInsertProfileItem(ctx, dbq.ImportInsertProfileItemParams{ + newID, err := dst.ImportInsertProfileItem(ctx, dbq.ImportInsertProfileItemParams{ ProfileID: newProfileID, Policy: item.Policy, ModFileVersionID: newMFVID, @@ -501,9 +511,11 @@ func importProfileItems(ctx context.Context, dst, src *dbq.Queries, oldGameInsta Notes: item.Notes, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, - }); err != nil { + }) + if err != nil { return fmt.Errorf("insert profile item: %w", err) } + remap.profileItems[item.ID] = newID } return nil } @@ -643,3 +655,45 @@ func importOverrides(ctx context.Context, dst, src *dbq.Queries, oldGameInstallI } return nil } + +func importSkipBackupPatterns(ctx context.Context, dst, src *dbq.Queries, oldGameInstallID int64, remap *idRemap) error { + patterns, err := src.ExportGetSkipBackupPatternsForGameInstall(ctx, oldGameInstallID) + if err != nil { + return fmt.Errorf("get skip-backup patterns: %w", err) + } + for _, p := range patterns { + newProfileItemID, ok := remap.profileItems[p.ProfileItemID] + if !ok { + return fmt.Errorf("skip-backup pattern references unknown profile item %d", p.ProfileItemID) + } + if err := dst.ImportInsertSkipBackupPattern(ctx, dbq.ImportInsertSkipBackupPatternParams{ + ProfileItemID: newProfileItemID, + Pattern: p.Pattern, + CreatedAt: p.CreatedAt, + }); err != nil { + return fmt.Errorf("insert skip-backup pattern %q: %w", p.Pattern, err) + } + } + return nil +} + +func importWriteOncePatterns(ctx context.Context, dst, src *dbq.Queries, oldGameInstallID int64, remap *idRemap) error { + patterns, err := src.ExportGetWriteOncePatternsForGameInstall(ctx, oldGameInstallID) + if err != nil { + return fmt.Errorf("get write-once patterns: %w", err) + } + for _, p := range patterns { + newProfileItemID, ok := remap.profileItems[p.ProfileItemID] + if !ok { + return fmt.Errorf("write-once pattern references unknown profile item %d", p.ProfileItemID) + } + if err := dst.ImportInsertWriteOncePattern(ctx, dbq.ImportInsertWriteOncePatternParams{ + ProfileItemID: newProfileItemID, + Pattern: p.Pattern, + CreatedAt: p.CreatedAt, + }); err != nil { + return fmt.Errorf("insert write-once pattern %q: %w", p.Pattern, err) + } + } + return nil +} diff --git a/migrations/00025_create_deploy_patterns.sql b/migrations/00025_create_deploy_patterns.sql new file mode 100644 index 0000000..5026650 --- /dev/null +++ b/migrations/00025_create_deploy_patterns.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE profile_item_skip_backup_patterns ( + id INTEGER PRIMARY KEY, + profile_item_id INTEGER NOT NULL REFERENCES profile_items(id) ON UPDATE CASCADE ON DELETE CASCADE, + pattern TEXT NOT NULL CHECK (LENGTH(pattern) > 0), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (profile_item_id, pattern) +) STRICT; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_pisbp_profile_item ON profile_item_skip_backup_patterns(profile_item_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE profile_item_write_once_patterns ( + id INTEGER PRIMARY KEY, + profile_item_id INTEGER NOT NULL REFERENCES profile_items(id) ON UPDATE CASCADE ON DELETE CASCADE, + pattern TEXT NOT NULL CHECK (LENGTH(pattern) > 0), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (profile_item_id, pattern) +) STRICT; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_piwop_profile_item ON profile_item_write_once_patterns(profile_item_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX idx_piwop_profile_item; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE profile_item_write_once_patterns; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP INDEX idx_pisbp_profile_item; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE profile_item_skip_backup_patterns; +-- +goose StatementEnd diff --git a/modctl.1.scd b/modctl.1.scd index 2bacc36..0821a8f 100644 --- a/modctl.1.scd +++ b/modctl.1.scd @@ -129,6 +129,33 @@ Set the active game: currently installed to that target (unapply first). Cannot remove auto-discovered targets such as _game_dir_ or _proton_prefix_. +*modctl games backups list* [--target ] + List all files that modctl has backed up for the current game. Pass + \--target to filter by a specific install target. + +*modctl games backups view* [--target ] [--force] + Print the content of a backed-up file to the terminal. The path is + relative to the target root. Binary files are refused unless --force + is passed. + +*modctl games backups delete* [--target ] + Delete a backup entry. modctl will no longer be able to restore the + original file at this path on unapply; the file will be deleted + instead. The blob is not immediately removed from disk; run + *modctl gc* to reclaim space. + +*modctl games backups restore* [--target ] [--force] + Restore a backed-up file to disk immediately without running a full + unapply. Warns if the active profile is currently applied, since + running apply again will overwrite this path. Requires --force if + the on-disk file has drifted from what modctl last installed. + +*modctl games backups diff* [--target ] [--force] + Show a unified diff between the backed-up content and the current + on-disk file. If the on-disk file is missing, the backup content is + shown as a full deletion. Binary files are refused unless --force + is passed. + ## Stores *modctl stores list* @@ -224,8 +251,8 @@ Set the active game: *modctl profiles upgrade* [--to ] Swap the mod file version currently in the active profile for a newer - one, preserving the existing priority slot, enabled state, and remap - rules. Without --to, modctl picks the most recently imported version + one, preserving the existing priority slot, enabled state, remap rules, and + deploy rules. Without --to, modctl picks the most recently imported version of the same mod file that is not already in the profile. With --to, the specified version is used directly. The old version is removed from the profile. @@ -273,6 +300,38 @@ Set the active game: they were excluded. The archive must be inventoried first; run *modctl mods scan-inventory* if needed. +*modctl profiles preview* [--target ] [--force] + Show a unified diff between the current on-disk file and what the + active profile's winning mod would write at that path. Requires archive + extraction which may be slow for large archives. Binary files are + refused unless --force is passed. + +*modctl profiles deploys skip-backup list* + List skip-backup patterns for a mod version in the active profile. + +*modctl profiles deploys skip-backup add* + Add a skip-backup pattern for a mod version in the active profile. + +*modctl profiles deploys skip-backup remove* + Remove a skip-backup pattern for a mod version in the active profile. + +*modctl profiles deploys skip-backup copy* + Copy skip-backup patterns from one mod version to another in the active + profile. Existing patterns on the destination are replaced. + +*modctl profiles deploys write-once list* + List write-once patterns for a mod version in the active profile. + +*modctl profiles deploys write-once add* + Add a write-once pattern for a mod version in the active profile. + +*modctl profiles deploys write-once remove* + Remove a write-once pattern for a mod version in the active profile. + +*modctl profiles deploys write-once copy* + Copy write-once patterns from one mod version to another in the active + profile. Existing patterns on the destination are replaced. + *modctl profiles overrides list* List all overrides for the active profile with a summary staleness status. Run *modctl profiles overrides status* for full detail. diff --git a/queries.sql b/queries.sql index 0d66a19..fafc699 100644 --- a/queries.sql +++ b/queries.sql @@ -2123,3 +2123,136 @@ WHERE target_id = ?; SELECT CAST(COUNT(*) AS INTEGER) AS count FROM profile_items WHERE target_id = ?; + +-- name: AddSkipBackupPattern :exec +INSERT INTO profile_item_skip_backup_patterns (profile_item_id, pattern) +VALUES (?, ?); + +-- name: RemoveSkipBackupPattern :execrows +DELETE FROM profile_item_skip_backup_patterns +WHERE profile_item_id = ? AND pattern = ?; + +-- name: ListSkipBackupPatterns :many +SELECT id, profile_item_id, pattern, created_at +FROM profile_item_skip_backup_patterns +WHERE profile_item_id = ? +ORDER BY pattern ASC; + +-- name: AddWriteOncePattern :exec +INSERT INTO profile_item_write_once_patterns (profile_item_id, pattern) +VALUES (?, ?); + +-- name: RemoveWriteOncePattern :execrows +DELETE FROM profile_item_write_once_patterns +WHERE profile_item_id = ? AND pattern = ?; + +-- name: ListWriteOncePatterns :many +SELECT id, profile_item_id, pattern, created_at +FROM profile_item_write_once_patterns +WHERE profile_item_id = ? +ORDER BY pattern ASC; + +-- name: GetSkipBackupPatternsForProfile :many +SELECT pi.id AS profile_item_id, p.pattern +FROM profile_item_skip_backup_patterns p +JOIN profile_items pi ON pi.id = p.profile_item_id +WHERE pi.profile_id = ? +ORDER BY pi.id, p.pattern; + +-- name: GetWriteOncePatternsForProfile :many +SELECT pi.id AS profile_item_id, p.pattern +FROM profile_item_write_once_patterns p +JOIN profile_items pi ON pi.id = p.profile_item_id +WHERE pi.profile_id = ? +ORDER BY pi.id, p.pattern; + +-- name: ExportGetSkipBackupPatternsForGameInstall :many +SELECT p.id, p.profile_item_id, p.pattern, p.created_at +FROM profile_item_skip_backup_patterns p +JOIN profile_items pi ON pi.id = p.profile_item_id +JOIN profiles pr ON pr.id = pi.profile_id +WHERE pr.game_install_id = ? +ORDER BY p.id; + +-- name: ExportInsertSkipBackupPattern :exec +INSERT INTO profile_item_skip_backup_patterns (id, profile_item_id, pattern, created_at) +VALUES (?, ?, ?, ?); + +-- name: ExportGetWriteOncePatternsForGameInstall :many +SELECT p.id, p.profile_item_id, p.pattern, p.created_at +FROM profile_item_write_once_patterns p +JOIN profile_items pi ON pi.id = p.profile_item_id +JOIN profiles pr ON pr.id = pi.profile_id +WHERE pr.game_install_id = ? +ORDER BY p.id; + +-- name: ExportInsertWriteOncePattern :exec +INSERT INTO profile_item_write_once_patterns (id, profile_item_id, pattern, created_at) +VALUES (?, ?, ?, ?); + +-- name: ImportInsertSkipBackupPattern :exec +INSERT INTO profile_item_skip_backup_patterns (profile_item_id, pattern, created_at) +VALUES (?, ?, ?); + +-- name: ImportInsertWriteOncePattern :exec +INSERT INTO profile_item_write_once_patterns (profile_item_id, pattern, created_at) +VALUES (?, ?, ?); + +-- name: DeleteAllSkipBackupPatterns :exec +DELETE FROM profile_item_skip_backup_patterns WHERE profile_item_id = ?; + +-- name: DeleteAllWriteOncePatterns :exec +DELETE FROM profile_item_write_once_patterns WHERE profile_item_id = ?; + +-- name: ListBackupsForGameInstall :many +SELECT + b.id, + b.relpath, + b.size_bytes, + b.created_at, + b.backup_blob_sha256, + b.original_content_sha256, + t.name AS target_name, + t.id AS target_id, + o.id AS operation_id, + o.op_type AS operation_type, + o.started_at AS operation_started_at +FROM backups b +JOIN targets t ON t.id = b.target_id +LEFT JOIN operations o ON o.id = b.created_by_operation_id +WHERE b.game_install_id = sqlc.arg(game_install_id) + AND (sqlc.arg(target_name)= '' OR t.name = sqlc.arg(target_name)) +ORDER BY t.name, b.relpath; + +-- name: GetBackupForGameInstallByPath :one +SELECT + b.id, + b.relpath, + b.size_bytes, + b.created_at, + b.backup_blob_sha256, + b.original_content_sha256, + t.name AS target_name, + t.id AS target_id, + o.id AS operation_id, + o.op_type AS operation_type, + o.started_at AS operation_started_at +FROM backups b +JOIN targets t ON t.id = b.target_id +LEFT JOIN operations o ON o.id = b.created_by_operation_id +WHERE b.game_install_id = ? + AND b.target_id = ? + AND b.relpath = ?; + +-- name: DeleteBackupByPath :execrows +DELETE FROM backups +WHERE game_install_id = ? + AND target_id = ? + AND relpath = ?; + +-- name: GetInstalledFileByPath :one +SELECT * +FROM installed_files +WHERE game_install_id = ? + AND target_id = ? + AND relpath = ?;