diff --git a/CHANGELOG.md b/CHANGELOG.md index 2562666..d9327e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ based on the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. `modctl` adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## unreleased + +This is another pre-release that adds new features. + +### Changes + +- Support multiple targets: auto-discover the proton prefix and allow arbitrary + custom-path targets. + ## v0.5.0 - 2026-03-27 This is another pre-release that adds a few new features based on testing. diff --git a/DESIGN.md b/DESIGN.md index d57c032..6a0a3a4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,15 +78,36 @@ and mod configuration. ### Target -A named install root within a `GameInstall`. v1 supports: -- `game_dir` only - -Future targets: -- `proton_prefix` -- `documents`, `appdata`, etc. - -Track installed files as `(game_install_id, target_id, relpath)` so we can -extend beyond game directory later. +A named install root within a `GameInstall`. Two tiers are supported: + +**Auto-discovered targets** are created and maintained by modctl during store +refresh. They cannot be removed manually and are recreated on the next refresh +if missing: +- `game_dir`: the game's install directory. Created for every game install. +- `proton_prefix`: the Wine C: drive root (`compatdata//pfx/drive_c`). + Created automatically for any Steam game that has a Proton compatdata + directory. Not created for native Linux games. + +**User-defined targets** are created manually via `games targets add` and +stored with `origin='user_override'`. They can be removed with +`games targets remove` provided no files are currently installed to that +target. Use `--relative-to ` to resolve a path relative to an +existing target at creation time (the resolved absolute path is stored and +the base target is not tracked after that point). + +For games where all mods deploy to a specific deep subdirectory (e.g. +UnityModManager under the Proton prefix), the recommended pattern is a +one-time `games targets add` that resolves to an absolute path, after which +`profiles add --target ` requires no remap rules for the common case. + +Track installed files as `(game_install_id, target_id, relpath)` so +deployment can span multiple roots. Conflict detection is scoped to +`(profile_id, target_id, relpath)` (two mods providing the same relative +path under different targets do not conflict). + +Targets with active installed files cannot be removed; the profile must be +unapplied first. Removing a target cascades to any profile items that +reference it (since nothing is on disk after unapply, this is safe). ### Mods model @@ -955,6 +976,21 @@ to the database, as they are internal Steam software rather than moddable games: - `Steam Linux Runtime *` (any title with this prefix, e.g. `Steam Linux Runtime 1.0`) - `Steamworks Common Redistributables` +#### Target discovery + +During refresh, modctl upserts two targets for each game install: + +**`game_dir`** is always upserted with `root_path = ` and +`origin = 'discovered'`. If a `user_override` row exists for the same name, +it is left untouched. + +**`proton_prefix`** is upserted only when +`/compatdata//pfx/drive_c` exists on disk. The resolved +absolute path is stored as `root_path` with `origin = 'discovered'`. If a +`user_override` row exists for the same name, it is left untouched. Games +that have never been launched under Proton, or native Linux games, will not +have a `proton_prefix` target. + ## 13. Extensibility for game-specific integrations ### Integration type @@ -1012,6 +1048,9 @@ This preserves a clean v1 while allowing richer v2. - Items are added to a profile enabled by default. The schema default is `FALSE` but the CLI overrides this at insert time. Use `--disabled` to explicitly add an item without enabling it. + - `profiles add` - add a mod file version to a profile. Use `--target ` + to specify which install target the mod deploys to (default: `game_dir`). + The target must exist for the current game install. - `profiles order compact|move|set|swap` - `profiles remap add|remove|list|clear|copy|preview` - manage remap rules for a mod version within a profile. Rules are appended by default; use @@ -1097,6 +1136,15 @@ This preserves a clean v1 while allowing richer v2. the config file - `operations list|show` - show specific actions that we took during apply/unapply +- `games targets list` - list all install targets for the active (or specified) + game install, showing name, root path, and origin +- `games targets add [--relative-to ]` - add a + user-defined install target. The path must be absolute unless + `--relative-to` is specified, in which case it is resolved relative to the + named target's root path at creation time and stored as an absolute path. +- `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. Key behavior: - "intent changes" (enable/disable/order) are cheap diff --git a/cmd/apply.go b/cmd/apply.go index 4049d8c..2782704 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -149,13 +149,25 @@ Use --dry-run to preview the plan without making any changes. Add the } defer unlock() - plan, err := planner.BuildApplyPlan(ctx, q, gi.ID, p.ID, applySkipRecheck) + targets, err := q.ListTargetsForGameInstall(ctx, gi.ID) 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("list targets: %w", err) + } + if len(targets) == 0 { + return fmt.Errorf("no targets found for game install %d", gi.ID) + } + + var plans []planner.Plan + for _, target := range targets { + plan, err := planner.BuildApplyPlan(ctx, q, gi.ID, p.ID, target, applySkipRecheck) + 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 plan for target %q: %w", target.Name, err) } - return fmt.Errorf("build plan: %w", err) + plans = append(plans, plan) } // TODO extract these styles somewhere @@ -169,7 +181,9 @@ Use --dry-run to preview the plan without making any changes. Add the // Dry-run output if applyDryRun { - printApplyPlan(plan, p.Name, gi.DisplayName, applyShowConflicts, boldStyle, subtleStyle, warnStyle, greenStyle, redStyle, yellowStyle, cyanStyle) + for _, plan := range plans { + printApplyPlan(plan, p.Name, gi.DisplayName, applyShowConflicts, boldStyle, subtleStyle, warnStyle, greenStyle, redStyle, yellowStyle, cyanStyle) + } return nil } @@ -210,47 +224,13 @@ Use --dry-run to preview the plan without making any changes. Add the sha256 string ops []planner.PlanOp } - archiveMap := make(map[string]*archiveGroup) - var archiveOrder []string - var overrideOps []planner.PlanOp - var removeOps []planner.PlanOp - var restoreOps []planner.PlanOp - - for _, planOp := range plan.Ops { - switch planOp.Kind { - case planner.PlanOpWrite, planner.PlanOpOverwrite: - if planOp.OverrideID.Valid { - overrideOps = append(overrideOps, planOp) - // For patch overrides, the base archive is handled via - // PatchBaseArchives below, not grouped here - } else { - sha := planOp.File.Winner().Entry.ArchiveSha256 - if _, ok := archiveMap[sha]; !ok { - archiveMap[sha] = &archiveGroup{sha256: sha} - archiveOrder = append(archiveOrder, sha) - } - archiveMap[sha].ops = append(archiveMap[sha].ops, planOp) - } - case planner.PlanOpRemove: - removeOps = append(removeOps, planOp) - case planner.PlanOpRestoreBackup: - restoreOps = append(restoreOps, planOp) - case planner.PlanOpNoop: - logger.Debug("skipping noop op", "path", planOp.DestPath) - } - } - // Add patch base archives to the staging set if not already present - // No ops added; archive is staged for patch base file access only - for _, sha := range plan.PatchBaseArchives { - if _, ok := archiveMap[sha]; !ok { - archiveMap[sha] = &archiveGroup{sha256: sha} - archiveOrder = append(archiveOrder, sha) - } + // Counters for summary + total := 0 + for _, plan := range plans { + total += len(plan.Ops) } - // Counters for summary - total := len(plan.Ops) current := 0 var ( countWrite int @@ -261,6 +241,16 @@ Use --dry-run to preview the plan without making any changes. Add the countFailed int ) + // markFailed marks the operation as failed and returns the error + markFailed := func(err error) error { + _ = q.FinishOperation(ctx, dbq.FinishOperationParams{ + Status: "failed", + Message: sql.NullString{String: err.Error(), Valid: true}, + ID: op.ID, + }) + return err + } + // Helper to print progress width := len(strconv.Itoa(total)) fmtCounter := fmt.Sprintf("[%%%dd/%%%dd]", width, width) @@ -283,42 +273,124 @@ Use --dry-run to preview the plan without making any changes. Add the } } - // markFailed marks the operation as failed and returns the error - markFailed := func(err error) error { - _ = q.FinishOperation(ctx, dbq.FinishOperationParams{ - Status: "failed", - Message: sql.NullString{String: err.Error(), Valid: true}, - ID: op.ID, - }) - return err - } + for _, plan := range plans { + archiveMap := make(map[string]*archiveGroup) + var archiveOrder []string + var overrideOps []planner.PlanOp + var removeOps []planner.PlanOp + var restoreOps []planner.PlanOp + + for _, planOp := range plan.Ops { + switch planOp.Kind { + case planner.PlanOpWrite, planner.PlanOpOverwrite: + if planOp.OverrideID.Valid { + overrideOps = append(overrideOps, planOp) + // For patch overrides, the base archive is handled via + // PatchBaseArchives below, not grouped here + } else { + sha := planOp.File.Winner().Entry.ArchiveSha256 + if _, ok := archiveMap[sha]; !ok { + archiveMap[sha] = &archiveGroup{sha256: sha} + archiveOrder = append(archiveOrder, sha) + } + archiveMap[sha].ops = append(archiveMap[sha].ops, planOp) + } + case planner.PlanOpRemove: + removeOps = append(removeOps, planOp) + case planner.PlanOpRestoreBackup: + restoreOps = append(restoreOps, planOp) + case planner.PlanOpNoop: + logger.Debug("skipping noop op", "path", planOp.DestPath) + } + } - // Extract and deploy archives - for _, sha := range archiveOrder { - group := archiveMap[sha] - stagingPath, err := ext.ExtractArchive(ctx, sha) - if err != nil { - return markFailed(fmt.Errorf("extract archive %.16s: %w", sha, err)) + // Add patch base archives to the staging set if not already present + // No ops added; archive is staged for patch base file access only + for _, sha := range plan.PatchBaseArchives { + if _, ok := archiveMap[sha]; !ok { + archiveMap[sha] = &archiveGroup{sha256: sha} + archiveOrder = append(archiveOrder, sha) + } + } + + // Extract and deploy archives + for _, sha := range archiveOrder { + group := archiveMap[sha] + stagingPath, err := ext.ExtractArchive(ctx, sha) + if err != nil { + return markFailed(fmt.Errorf("extract archive %.16s: %w", sha, err)) + } + + for _, planOp := range group.ops { + symbol := greenStyle.Render("+") + detail := "" + if planOp.Kind == planner.PlanOpOverwrite { + symbol = yellowStyle.Render("~") + } + if planOp.NeedsBackup { + detail = "(backing up original)" + } + printOp(symbol, planOp.DestPath, detail) + + result, err := ext.DeployFile(ctx, db, q, planOp, stagingPath, plan.TargetRoot, gi.ID, plan.TargetID, p.ID, op.ID) + if err != nil { + countFailed++ + if applyVerbose { + fmt.Println(warnStyle.Render(fmt.Sprintf(" ✗ %v", err))) + } + return markFailed(fmt.Errorf("deploy %q: %w", planOp.DestPath, err)) + } + + if planOp.Kind == planner.PlanOpOverwrite { + countOverwrite++ + } else { + countWrite++ + } + if result.WasBackedUp { + countBackedUp++ + } + } } - for _, planOp := range group.ops { + // Deploy override ops + for _, planOp := range overrideOps { symbol := greenStyle.Render("+") - detail := "" + detail := "(override)" if planOp.Kind == planner.PlanOpOverwrite { symbol = yellowStyle.Render("~") } if planOp.NeedsBackup { - detail = "(backing up original)" + detail = "(override, backing up original)" } printOp(symbol, planOp.DestPath, detail) - result, err := ext.DeployFile(ctx, db, q, planOp, stagingPath, plan.TargetRoot, gi.ID, plan.TargetID, p.ID, op.ID) + // Load patch entries if needed + var patchEntries []patchapply.Entry + if planOp.OverrideType != "full_file" { + dbEntries, err := q.ListOverridePatchEntries(ctx, planOp.OverrideID.Int64) + if err != nil { + countFailed++ + return markFailed(fmt.Errorf("load patch entries for %q: %w", planOp.DestPath, err)) + } + for _, e := range dbEntries { + patchEntries = append(patchEntries, patchapply.Entry{ + PatchType: e.PatchType, + EntrySection: e.EntrySection.String, + EntryKey: e.EntryKey, + EntryValue: e.EntryValue.String, + }) + } + } + + result, err := ext.DeployOverrideFile( + ctx, db, q, planOp, + ext.StagingPathFor(planOp.OverrideBaseArchiveSha256.String), + plan.TargetRoot, gi.ID, plan.TargetID, p.ID, op.ID, + patchEntries, + ) if err != nil { countFailed++ - if applyVerbose { - fmt.Println(warnStyle.Render(fmt.Sprintf(" ✗ %v", err))) - } - return markFailed(fmt.Errorf("deploy %q: %w", planOp.DestPath, err)) + return markFailed(fmt.Errorf("deploy override %q: %w", planOp.DestPath, err)) } if planOp.Kind == planner.PlanOpOverwrite { @@ -330,86 +402,35 @@ Use --dry-run to preview the plan without making any changes. Add the countBackedUp++ } } - } - // Deploy override ops - for _, planOp := range overrideOps { - symbol := greenStyle.Render("+") - detail := "(override)" - if planOp.Kind == planner.PlanOpOverwrite { - symbol = yellowStyle.Render("~") - } - if planOp.NeedsBackup { - detail = "(override, backing up original)" - } - printOp(symbol, planOp.DestPath, detail) + var removedPaths []string - // Load patch entries if needed - var patchEntries []patchapply.Entry - if planOp.OverrideType != "full_file" { - dbEntries, err := q.ListOverridePatchEntries(ctx, planOp.OverrideID.Int64) - if err != nil { + // Remove ops + for _, planOp := range removeOps { + printOp(redStyle.Render("-"), planOp.DestPath, "") + if _, err := ext.RemoveFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { countFailed++ - return markFailed(fmt.Errorf("load patch entries for %q: %w", planOp.DestPath, err)) - } - for _, e := range dbEntries { - patchEntries = append(patchEntries, patchapply.Entry{ - PatchType: e.PatchType, - EntrySection: e.EntrySection.String, - EntryKey: e.EntryKey, - EntryValue: e.EntryValue.String, - }) + return markFailed(fmt.Errorf("remove %q: %w", planOp.DestPath, err)) } + countRemove++ + removedPaths = append(removedPaths, planOp.DestPath) } - result, err := ext.DeployOverrideFile( - ctx, db, q, planOp, - ext.StagingPathFor(planOp.OverrideBaseArchiveSha256.String), - plan.TargetRoot, gi.ID, plan.TargetID, p.ID, op.ID, - patchEntries, - ) - if err != nil { - countFailed++ - return markFailed(fmt.Errorf("deploy override %q: %w", planOp.DestPath, err)) - } - - if planOp.Kind == planner.PlanOpOverwrite { - countOverwrite++ - } else { - countWrite++ - } - if result.WasBackedUp { - countBackedUp++ - } - } - - var removedPaths []string - - // Remove ops - for _, planOp := range removeOps { - printOp(redStyle.Render("-"), planOp.DestPath, "") - if _, err := ext.RemoveFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { - countFailed++ - return markFailed(fmt.Errorf("remove %q: %w", planOp.DestPath, err)) + // Restore ops + for _, planOp := range restoreOps { + printOp(cyanStyle.Render("↩"), planOp.DestPath, "") + if _, err := ext.RestoreFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { + countFailed++ + return markFailed(fmt.Errorf("restore %q: %w", planOp.DestPath, err)) + } + countRestore++ } - countRemove++ - removedPaths = append(removedPaths, planOp.DestPath) - } - // Restore ops - for _, planOp := range restoreOps { - printOp(cyanStyle.Render("↩"), planOp.DestPath, "") - if _, err := ext.RestoreFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { - countFailed++ - return markFailed(fmt.Errorf("restore %q: %w", planOp.DestPath, err)) + // Prune empty directories if requested + if applyPruneDirs { + pruneWarnings := extractor.PruneDirs(plan.TargetRoot, removedPaths) + plan.Warnings = append(plan.Warnings, pruneWarnings...) } - countRestore++ - } - - // Prune empty directories if requested - if applyPruneDirs { - pruneWarnings := extractor.PruneDirs(plan.TargetRoot, removedPaths) - plan.Warnings = append(plan.Warnings, pruneWarnings...) } // Clear the spinner line before printing summary @@ -474,9 +495,13 @@ Use --dry-run to preview the plan without making any changes. Add the if countBackedUp > 0 { fmt.Printf(" backed up: %d\n", countBackedUp) } - if len(plan.Warnings) > 0 { - fmt.Println(warnStyle.Render(fmt.Sprintf(" warnings: %d", len(plan.Warnings)))) - for _, w := range plan.Warnings { + var allWarnings []string + for _, plan := range plans { + allWarnings = append(allWarnings, plan.Warnings...) + } + if len(allWarnings) > 0 { + fmt.Println(warnStyle.Render(fmt.Sprintf(" warnings: %d", len(allWarnings)))) + for _, w := range allWarnings { fmt.Println(warnStyle.Render(" ⚠ " + w)) } } diff --git a/cmd/games_targets.go b/cmd/games_targets.go new file mode 100644 index 0000000..ad1b8c0 --- /dev/null +++ b/cmd/games_targets.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 gamesTargetsCmd = &cobra.Command{ + Use: "targets", + Short: "Manage install targets for a game", +} + +func init() { + gamesCmd.AddCommand(gamesTargetsCmd) +} diff --git a/cmd/games_targets_add.go b/cmd/games_targets_add.go new file mode 100644 index 0000000..3a03c15 --- /dev/null +++ b/cmd/games_targets_add.go @@ -0,0 +1,145 @@ +/* + * 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 ( + "database/sql" + "errors" + "fmt" + "path/filepath" + + "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 ( + gamesTargetsAddGame string + gamesTargetsAddRelTo string +) + +var gamesTargetsAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a custom install target for a game", + Long: `Add a user-defined install target for a game. + +The path is stored as an absolute path. Use --relative-to to specify another +target name to resolve the path relative to. For example: + + modctl games targets add unitymodmanager \ + "users/steamuser/AppData/LocalLow/Owlcat Games/Rogue Trader/UnityModManager" \ + --relative-to proton_prefix + +The path is resolved to an absolute path at creation time. If the base target +moves later, this target will not be updated automatically.`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + name := args[0] + rawPath := args[1] + + 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 gamesTargetsAddGame == "" { + 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") + } + gamesTargetsAddGame = fmt.Sprintf("%d", active.ActiveGameInstallID) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesTargetsAddGame) + if err != nil { + return err + } + + // Resolve the final absolute path + var absPath string + if gamesTargetsAddRelTo != "" { + base, err := q.GetTargetByGameInstallAndName(ctx, dbq.GetTargetByGameInstallAndNameParams{ + GameInstallID: gi.ID, + Name: gamesTargetsAddRelTo, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("base target %q not found; run `modctl games targets list` to see available targets", gamesTargetsAddRelTo) + } + return fmt.Errorf("resolve base target %q: %w", gamesTargetsAddRelTo, err) + } + absPath = filepath.Join(base.RootPath, rawPath) + } else { + if !filepath.IsAbs(rawPath) { + return fmt.Errorf("path %q is not absolute; use --relative-to to specify a base target or provide an absolute path", rawPath) + } + absPath = filepath.Clean(rawPath) + } + + target, err := q.InsertUserTarget(ctx, dbq.InsertUserTargetParams{ + GameInstallID: gi.ID, + Name: name, + RootPath: absPath, + }) + if err != nil { + return fmt.Errorf("add target: %w", err) + } + + fmt.Printf("Added target %q (id=%d) → %s\n", target.Name, target.ID, target.RootPath) + return nil + }, +} + +func init() { + gamesTargetsCmd.AddCommand(gamesTargetsAddCmd) + + gamesTargetsAddCmd.Flags().StringVarP(&gamesTargetsAddGame, "game", "g", "", + "Override the currently active game") + gamesTargetsAddCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) + + gamesTargetsAddCmd.Flags().StringVar(&gamesTargetsAddRelTo, "relative-to", "", + "Name of an existing target to resolve the path relative to") + gamesTargetsAddCmd.RegisterFlagCompletionFunc("relative-to", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) +} diff --git a/cmd/games_targets_list.go b/cmd/games_targets_list.go new file mode 100644 index 0000000..a8a0106 --- /dev/null +++ b/cmd/games_targets_list.go @@ -0,0 +1,112 @@ +/* + * 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" + + "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 gamesTargetsListGame string + +var gamesTargetsListCmd = &cobra.Command{ + Use: "list", + Short: "List install targets for a game", + Args: cobra.ExactArgs(0), + 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 gamesTargetsListGame == "" { + 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") + } + gamesTargetsListGame = fmt.Sprintf("%d", active.ActiveGameInstallID) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesTargetsListGame) + if err != nil { + return err + } + + targets, err := q.ListTargetsForGameInstall(ctx, gi.ID) + if err != nil { + return fmt.Errorf("list targets: %w", err) + } + + if len(targets) == 0 { + fmt.Println("No targets found.") + return nil + } + + rows := [][]string{} + for _, t := range targets { + rows = append(rows, []string{ + fmt.Sprintf(" %d ", t.ID), + fmt.Sprintf(" %s ", t.Name), + fmt.Sprintf(" %s ", t.RootPath), + fmt.Sprintf(" %s ", t.Origin), + }) + } + + tbl := table.New(). + Headers(" ID ", " Name ", " Path ", " Origin "). + Rows(rows...) + fmt.Println(tbl) + return nil + }, +} + +func init() { + gamesTargetsCmd.AddCommand(gamesTargetsListCmd) + + gamesTargetsListCmd.Flags().StringVarP(&gamesTargetsListGame, "game", "g", "", + "Override the currently active game") + gamesTargetsListCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) +} diff --git a/cmd/games_targets_remove.go b/cmd/games_targets_remove.go new file mode 100644 index 0000000..580e56b --- /dev/null +++ b/cmd/games_targets_remove.go @@ -0,0 +1,131 @@ +/* + * 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 ( + "database/sql" + "errors" + "fmt" + + "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 gamesTargetsRemoveGame string + +var gamesTargetsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a custom install target", + Long: `Remove a user-defined install target. + +Discovered targets (game_dir, proton_prefix) cannot be removed. + +The target cannot be removed if any installed files still reference it. +Unapply any mods using this target first.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.TargetNames(cmd, toComplete) + }, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + name := args[0] + + 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 gamesTargetsRemoveGame == "" { + 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") + } + gamesTargetsRemoveGame = fmt.Sprintf("%d", active.ActiveGameInstallID) + } + + gi, err := argresolver.ResolveGameInstallArg(ctx, q, gamesTargetsRemoveGame) + if err != nil { + return err + } + + target, err := q.GetTargetByGameInstallAndName(ctx, dbq.GetTargetByGameInstallAndNameParams{ + GameInstallID: gi.ID, + Name: name, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("target %q not found", name) + } + return fmt.Errorf("get target: %w", err) + } + + if target.Origin == "discovered" { + return fmt.Errorf("target %q is auto-discovered and cannot be removed", name) + } + + installedCount, err := q.CountInstalledFilesForTarget(ctx, target.ID) + if err != nil { + return fmt.Errorf("check installed files: %w", err) + } + if installedCount > 0 { + return fmt.Errorf("target %q has %d installed file(s); unapply the profile before removing this target", name, installedCount) + } + + if err := q.DeleteTarget(ctx, target.ID); err != nil { + return fmt.Errorf("delete target: %w", err) + } + + fmt.Printf("Removed target %q.\n", name) + return nil + }, +} + +func init() { + gamesTargetsCmd.AddCommand(gamesTargetsRemoveCmd) + + gamesTargetsRemoveCmd.Flags().StringVarP(&gamesTargetsRemoveGame, "game", "g", "", + "Override the currently active game") + gamesTargetsRemoveCmd.RegisterFlagCompletionFunc("game", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GameInstallSelectors(cmd, toComplete) + }) +} diff --git a/cmd/profiles_add.go b/cmd/profiles_add.go index 097ab57..590cd06 100644 --- a/cmd/profiles_add.go +++ b/cmd/profiles_add.go @@ -31,11 +31,13 @@ import ( "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/state" "github.com/spf13/cobra" + "go.finelli.dev/util" ) var ( profilesAddGame string profilesAddProfile string + profilesAddTarget string profilesAddPriority int64 profilesAddDisabled bool @@ -50,7 +52,11 @@ By default, this adds to the active profile for the current game. You can override the target profile with --profile. If --priority is not provided, modctl assigns the next highest priority in the -profile. Higher priority wins conflicts.`, +profile. Higher priority wins conflicts. + +Use --target to specify which install target the mod should be deployed to +(e.g. "game_dir", "proton_prefix", or a custom target name). Defaults to +"game_dir".`, Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { @@ -107,6 +113,23 @@ profile. Higher priority wins conflicts.`, return err } + // Resolve target: default to game_dir. + targetName := profilesAddTarget + if targetName == "" { + targetName = "game_dir" + } + + target, err := q.GetTargetByGameInstallAndName(ctx, dbq.GetTargetByGameInstallAndNameParams{ + GameInstallID: gi.ID, + Name: targetName, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("target %q not found for game %q; run `modctl games targets list` to see available targets", targetName, gi.DisplayName) + } + return fmt.Errorf("resolve target %q: %w", targetName, err) + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("error starting transaction: %w", err) @@ -144,14 +167,15 @@ profile. Higher priority wins conflicts.`, } } - enabledVal := int64(1) // default enabled=true + enabledVal := util.SqliteBoolToInt(true) // default enabled=true if profilesAddDisabled { - enabledVal = 0 + enabledVal = util.SqliteBoolToInt(false) } itemID, err := qtx.CreateProfileItem(ctx, dbq.CreateProfileItemParams{ ProfileID: p.ID, ModFileVersionID: mfv.ID, + TargetID: target.ID, Enabled: enabledVal, Priority: priority, }) @@ -184,8 +208,8 @@ profile. Higher priority wins conflicts.`, return fmt.Errorf("commit: %w", err) } - fmt.Printf("Added version %d to profile %q (item_id=%d, priority=%d, enabled=%t)\n", - mfv.ID, p.Name, itemID, priority, enabledVal != 0) + fmt.Printf("Added version %d to profile %q (item_id=%d, priority=%d, enabled=%t, target=%s)\n", + mfv.ID, p.Name, itemID, priority, enabledVal != 0, target.Name) return nil }, @@ -208,6 +232,13 @@ func init() { return completion.ProfileNames(cmd, toComplete) }) + profilesAddCmd.Flags().StringVarP(&profilesAddTarget, "target", "t", "", + `Install target to deploy this mod to (default "game_dir")`) + profilesAddCmd.RegisterFlagCompletionFunc("target", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.TargetNames(cmd, toComplete) + }) + profilesAddCmd.Flags().Int64Var(&profilesAddPriority, "priority", 0, "Priority (higher wins conflicts). Defaults to next available.") diff --git a/cmd/profiles_status.go b/cmd/profiles_status.go index df67c11..3829821 100644 --- a/cmd/profiles_status.go +++ b/cmd/profiles_status.go @@ -376,6 +376,10 @@ func renderProfileStatus( item.RemapRuleCount, item.ModFileVersionID))) } + if item.TargetName != "game_dir" { + writeKVIndented16(&b, "target:", item.TargetName) + } + b.WriteString("\n") } } diff --git a/cmd/unapply.go b/cmd/unapply.go index 5805b5e..5a22780 100644 --- a/cmd/unapply.go +++ b/cmd/unapply.go @@ -147,15 +147,31 @@ Use --dry-run to preview the plan without making any changes.`, } defer unlock() - // Build the unapply plan. - plan, err := planner.BuildUnapplyPlan(ctx, q, gi.ID) + // Build the unapply plans + targets, err := q.ListTargetsForGameInstall(ctx, gi.ID) if err != nil { - return fmt.Errorf("build unapply plan: %w", err) + return fmt.Errorf("list targets: %w", err) + } + if len(targets) == 0 { + return fmt.Errorf("no targets found for game install %d", gi.ID) + } + + var plans []planner.Plan + for _, target := range targets { + plan, err := planner.BuildUnapplyPlan(ctx, q, gi.ID, target) + if err != nil { + return fmt.Errorf("build unapply plan for target %q: %w", target.Name, err) + } + plans = append(plans, plan) } - if len(plan.Ops) == 0 { + totalOps := 0 + for _, plan := range plans { + totalOps += len(plan.Ops) + } + if totalOps == 0 { fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render( - " nothing to unapply - no tool-managed files found")) + " nothing to unapply: no tool-managed files found")) return nil } @@ -168,7 +184,9 @@ Use --dry-run to preview the plan without making any changes.`, // Dry-run output if unapplyDryRun { - printUnapplyPlan(plan, gi.DisplayName, appliedProfileName, boldStyle, subtleStyle, warnStyle, redStyle, cyanStyle) + for _, plan := range plans { + printUnapplyPlan(plan, gi.DisplayName, appliedProfileName, boldStyle, subtleStyle, warnStyle, redStyle, cyanStyle) + } return nil } @@ -203,7 +221,7 @@ Use --dry-run to preview the plan without making any changes.`, } // Counters - total := len(plan.Ops) + total := totalOps current := 0 var ( countRemove int @@ -238,36 +256,41 @@ Use --dry-run to preview the plan without making any changes.`, return err } - var removedPaths []string - - for _, planOp := range plan.Ops { - switch planOp.Kind { - case planner.PlanOpRemove: - printOp(redStyle.Render("-"), planOp.DestPath) - if _, err := ext.RemoveFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { - countFailed++ - return markFailed(fmt.Errorf("remove %q: %w", planOp.DestPath, err)) - } - countRemove++ - removedPaths = append(removedPaths, planOp.DestPath) - - case planner.PlanOpRestoreBackup: - printOp(cyanStyle.Render("↩"), planOp.DestPath) - if _, err := ext.RestoreFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { - countFailed++ - return markFailed(fmt.Errorf("restore %q: %w", planOp.DestPath, err)) + var allWarnings []string + + for _, plan := range plans { + var removedPaths []string + + for _, planOp := range plan.Ops { + switch planOp.Kind { + case planner.PlanOpRemove: + printOp(redStyle.Render("-"), planOp.DestPath) + if _, err := ext.RemoveFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { + countFailed++ + return markFailed(fmt.Errorf("remove %q: %w", planOp.DestPath, err)) + } + countRemove++ + removedPaths = append(removedPaths, planOp.DestPath) + + case planner.PlanOpRestoreBackup: + printOp(cyanStyle.Render("↩"), planOp.DestPath) + if _, err := ext.RestoreFile(ctx, db, q, planOp, plan.TargetRoot, gi.ID, plan.TargetID, op.ID); err != nil { + countFailed++ + return markFailed(fmt.Errorf("restore %q: %w", planOp.DestPath, err)) + } + countRestore++ + + default: + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("unexpected op kind %q for %q during unapply - skipped", planOp.Kind, planOp.DestPath)) } - countRestore++ - - default: - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("unexpected op kind %q for %q during unapply - skipped", planOp.Kind, planOp.DestPath)) } - } - if unapplyPruneDirs { - pruneWarnings := extractor.PruneDirs(plan.TargetRoot, removedPaths) - plan.Warnings = append(plan.Warnings, pruneWarnings...) + if unapplyPruneDirs { + pruneWarnings := extractor.PruneDirs(plan.TargetRoot, removedPaths) + allWarnings = append(allWarnings, pruneWarnings...) + } + allWarnings = append(allWarnings, plan.Warnings...) } // Clear spinner line @@ -309,9 +332,9 @@ Use --dry-run to preview the plan without making any changes.`, if countRestore > 0 { fmt.Printf(" restored: %d\n", countRestore) } - if len(plan.Warnings) > 0 { - fmt.Println(warnStyle.Render(fmt.Sprintf(" warnings: %d", len(plan.Warnings)))) - for _, w := range plan.Warnings { + if len(allWarnings) > 0 { + fmt.Println(warnStyle.Render(fmt.Sprintf(" warnings: %d", len(allWarnings)))) + for _, w := range allWarnings { fmt.Println(warnStyle.Render(" ⚠ " + w)) } } diff --git a/docs/01_overview.md b/docs/01_overview.md index 90bd728..c0c2bda 100644 --- a/docs/01_overview.md +++ b/docs/01_overview.md @@ -40,9 +40,12 @@ In practice most people have one install per game, but the distinction matters for the future when multiple stores are supported. Each game install has one or more **targets**: named locations where mods can -be installed. In v1 the only target is `game_dir`, the game's installation -directory. Future versions will add support for Proton prefixes, documents -folders, and other locations. +be deployed. The `game_dir` target (the game's installation directory) is +always present. For games that run under Proton, modctl also creates a +`proton_prefix` target automatically, pointing at the Wine C: drive root +inside the Proton prefix. For games that expect mods in a specific +subdirectory you can define additional, arbitrary named targets. See the +[games command reference](../commands/games-stores#targets) for details. ## Mods diff --git a/docs/06_commands/02_profiles.md b/docs/06_commands/02_profiles.md index 13d6121..1419982 100644 --- a/docs/06_commands/02_profiles.md +++ b/docs/06_commands/02_profiles.md @@ -68,7 +68,7 @@ modctl profiles status | Flag | Description | |-------------|---------------------------------| -| `--compace` | Shorter one-line-per-mod output | +| `--compact` | Shorter one-line-per-mod output | ### profiles diff @@ -93,10 +93,18 @@ automatically. modctl profiles add "Appearance Menu Mod" ``` -| Flag | Description | -|------------------|----------------------------------| -| `--priority ` | Assign a specific priority value | -| `--disabled` | Add the mod without enabling it | +Use `--target` to specify which install target the mod deploys to. If not +provided, defaults to `game_dir`. The target must already exist for the +current game; run `modctl games targets list` to see available targets. +```bash +modctl profiles add "ToyBox" --target unitymodmanager +``` + +| Flag | Description | +|-------------------|-----------------------------------------------------------| +| `--priority ` | Assign a specific priority value | +| `--disabled` | Add the mod without enabling it | +| `--target ` | Deploy to a specific install target (default: `game_dir`) | ### profiles remove diff --git a/docs/06_commands/04_games-stores.md b/docs/06_commands/04_games-stores.md index abfe340..e715b11 100644 --- a/docs/06_commands/04_games-stores.md +++ b/docs/06_commands/04_games-stores.md @@ -23,6 +23,12 @@ Some internal Steam titles are filtered automatically and never added to the database, as they are not moddable games: Proton Experimental, Steam Linux Runtime, and Steamworks Common Redistributables. +During refresh, modctl also creates or updates install targets for each +game. The `game_dir` target is always created. For games that run under +Proton, a `proton_prefix` target is also created automatically, pointing +at the Wine C: drive root inside the Proton prefix. User-defined targets +are never overwritten by refresh. + Safe to run at any time and as often as you like. ### games list @@ -68,6 +74,64 @@ candidates and ask you to be more specific using a selector. If multiple installs exist for the same game you must specify the instance explicitly. +## Targets + +Each game install has one or more named targets: locations where mods can +be deployed. Targets are the bridge between a profile item and the +filesystem; when you add a mod to a profile you specify which target it +deploys to. + +Two targets are managed automatically by modctl: + +- `game_dir`: the game's installation directory. Present for every game. +- `proton_prefix`: the Wine C: drive root inside the Proton prefix + (`compatdata//pfx/drive_c`). Created automatically during refresh + for games that run under Proton. Not present for native Linux games. + +For games that expect mods in a specific subdirectory (for example, a Unity +mod manager folder deep inside the Proton prefix), define a named custom +target once and all mods that belong there can reference it by name without +remap rules. + +### games targets list + +List all install targets for the current game, showing the name, root path, +and whether each target was auto-discovered or user-defined. +```bash +modctl games targets list +``` + +### games targets add + +Add a user-defined install target. The path must be absolute, or relative +to an existing target using `--relative-to`. +```bash +modctl games targets add saves ~/.local/share/MyGame/saves +``` + +Use `--relative-to` to build on top of an existing target without needing +to know the full path. The path is resolved to an absolute path at creation +time. If the base target moves later, this target will not update +automatically. +```bash +modctl games targets add unitymodmanager \ + "users/steamuser/AppData/LocalLow/Owlcat Games/Rogue Trader/UnityModManager" \ + --relative-to proton_prefix +``` + +| Flag | Description | +|------------------------|-------------------------------------------------------------| +| `--relative-to ` | Resolve the path relative to an existing target's root path | + +### games targets remove + +Remove a user-defined install target. Refuses if any files are currently +installed to that target (unapply the profile first). Auto-discovered +targets (`game_dir`, `proton_prefix`) cannot be removed. +```bash +modctl games targets remove saves +``` + --- ## stores diff --git a/internal/completion/targets.go b/internal/completion/targets.go new file mode 100644 index 0000000..1357091 --- /dev/null +++ b/internal/completion/targets.go @@ -0,0 +1,75 @@ +/* + * 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 completion + +import ( + "context" + "fmt" + "strings" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" +) + +// TargetNames completes target names for the current game install. +// If the command has a --game flag set, it is used; otherwise the active game +// is used. +// +// Returns candidates in "name\troot_path" format so the user can see where +// each target points without having to run a separate command. +func TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + ctx := context.Background() + db, err := internal.SetupDBReadOnly() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer db.Close() + + var gameID int64 + if f := cmd.Flags().Lookup("game"); f != nil && f.Changed { + v, err := cmd.Flags().GetInt64("game") + if err != nil || v <= 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + gameID = v + } else { + active, err := state.LoadActive() + if err != nil || active.ActiveGameInstallID <= 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + gameID = active.ActiveGameInstallID + } + + q := dbq.New(db) + rows, err := q.ListTargetsForGameInstall(ctx, gameID) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + needle := strings.ToLower(toComplete) + out := make([]string, 0, len(rows)) + for _, t := range rows { + if strings.HasPrefix(strings.ToLower(t.Name), needle) { + out = append(out, fmt.Sprintf("%s\t%s", t.Name, t.RootPath)) + } + } + return out, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 902cbcd..815353a 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -144,18 +144,7 @@ func (e *UninventoriedArchiveError) Error() string { // BuildApplyPlan computes the desired file state for applying profileID to // gameInstallID. It reads the filesystem to check file existence and // ownership but does not modify anything. -func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileID int64, skipRecheck bool) (Plan, error) { - target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ - GameInstallID: gameInstallID, - Name: "game_dir", - }) - if err != nil { - if err == sql.ErrNoRows { - return Plan{}, fmt.Errorf("no game_dir target found for game install %d", gameInstallID) - } - return Plan{}, fmt.Errorf("resolve target: %w", err) - } - +func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileID int64, target dbq.Target, skipRecheck bool) (Plan, error) { plan := Plan{ GameInstallID: gameInstallID, ProfileID: profileID, @@ -163,8 +152,11 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI TargetRoot: target.RootPath, } - // Load enabled profile items sorted by priority desc. - items, err := q.GetProfileItemForPlanning(ctx, profileID) + // Load enabled profile items (for this target) sorted by priority desc + items, err := q.GetProfileItemsForPlanning(ctx, dbq.GetProfileItemsForPlanningParams{ + ProfileID: profileID, + TargetID: target.ID, + }) if err != nil { return Plan{}, fmt.Errorf("load profile items: %w", err) } @@ -491,18 +483,7 @@ func BuildApplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID, profileI // BuildUnapplyPlan computes the operations needed to remove all tool-managed // files for a game install. It does not require the profile to still exist. -func BuildUnapplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID int64) (Plan, error) { - target, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ - GameInstallID: gameInstallID, - Name: "game_dir", - }) - if err != nil { - if err == sql.ErrNoRows { - return Plan{}, fmt.Errorf("no game_dir target found for game install %d", gameInstallID) - } - return Plan{}, fmt.Errorf("resolve target: %w", err) - } - +func BuildUnapplyPlan(ctx context.Context, q *dbq.Queries, gameInstallID int64, target dbq.Target) (Plan, error) { plan := Plan{ GameInstallID: gameInstallID, TargetID: target.ID, diff --git a/internal/refresh.go b/internal/refresh.go index 2077d0c..032beb5 100644 --- a/internal/refresh.go +++ b/internal/refresh.go @@ -22,7 +22,6 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -65,6 +64,11 @@ type RefreshStyles struct { Cyan lipgloss.Style } +type steamInstall struct { + params dbq.UpsertGameInstallParams + steamappsDir string // e.g. ~/.local/share/Steam/steamapps +} + func ScanStores(ctx context.Context, db *sql.DB, styles RefreshStyles) (RefreshResult, error) { q := dbq.New(db) stores, err := q.ListEnabledStores(ctx) @@ -144,7 +148,7 @@ func refreshSteam(ctx context.Context, db *sql.DB, q *dbq.Queries, styles Refres type upsertKey struct{ gameID, instanceID string } upsertSet := make(map[upsertKey]struct{}, len(installs)) for _, di := range installs { - upsertSet[upsertKey{di.StoreGameID, di.InstanceID}] = struct{}{} + upsertSet[upsertKey{di.params.StoreGameID, di.params.InstanceID}] = struct{}{} } // Detect missing: was present before, not in discovered set now @@ -170,14 +174,18 @@ func refreshSteam(ctx context.Context, db *sql.DB, q *dbq.Queries, styles Refres } for _, di := range installs { - id, err := qtx.UpsertGameInstall(ctx, di) + id, err := qtx.UpsertGameInstall(ctx, di.params) if err != nil { return result, fmt.Errorf("upsert game install %s:%s#%s: %w", - di.StoreID, di.StoreGameID, di.InstanceID, err) + di.params.StoreID, di.params.StoreGameID, di.params.InstanceID, err) + } + + if err := upsertGameDirTarget(ctx, qtx, id, di.params.InstallRoot); err != nil { + return result, fmt.Errorf("error upserting game_dir target: %w", err) } - if err := upsertGameDirTarget(ctx, qtx, id, di.InstallRoot); err != nil { - return result, fmt.Errorf("error upserting target dir: %w", err) + if err := upsertProtonPrefixTarget(ctx, qtx, id, di.steamappsDir, di.params.StoreGameID); err != nil { + return result, fmt.Errorf("error upserting proton_prefix target: %w", err) } if err := qtx.EnsureDefaultProfile(ctx, id); err != nil { @@ -185,18 +193,18 @@ func refreshSteam(ctx context.Context, db *sql.DB, q *dbq.Queries, styles Refres } // Classify and print - k := existingKey{di.StoreGameID, di.InstanceID} + k := existingKey{di.params.StoreGameID, di.params.InstanceID} if prev, known := existingMap[k]; !known { - result.New = append(result.New, di.DisplayName) - fmt.Println(styles.Green.Render(fmt.Sprintf(" + %s", di.DisplayName)) + + result.New = append(result.New, di.params.DisplayName) + fmt.Println(styles.Green.Render(fmt.Sprintf(" + %s", di.params.DisplayName)) + styles.Subtle.Render(" (new)")) } else if !prev.isPresent { - result.Returned = append(result.Returned, di.DisplayName) - fmt.Println(styles.Cyan.Render(fmt.Sprintf(" ↩ %s", di.DisplayName)) + + result.Returned = append(result.Returned, di.params.DisplayName) + fmt.Println(styles.Cyan.Render(fmt.Sprintf(" ↩ %s", di.params.DisplayName)) + styles.Subtle.Render(" (returned)")) } else { - result.Updated = append(result.Updated, di.DisplayName) - fmt.Println(styles.Subtle.Render(fmt.Sprintf(" = %s", di.DisplayName))) + result.Updated = append(result.Updated, di.params.DisplayName) + fmt.Println(styles.Subtle.Render(fmt.Sprintf(" = %s", di.params.DisplayName))) } } @@ -328,7 +336,7 @@ func assignSteamInstanceIDs(libs []string) map[string]string { func discoverSteamInstalls( libraryRoots []string, // canonical library roots instanceByLib map[string]string, // canonical lib root -> instance_id -) ([]dbq.UpsertGameInstallParams, []string, []string, error) { +) ([]steamInstall, []string, []string, error) { // for each lib: // - list steamapps/appmanifest_*.acf // - parse @@ -338,7 +346,7 @@ func discoverSteamInstalls( // - metadata: include install_root_raw + library_root (+ manifest_path) warnings := []string{} skipped := []string{} - installs := []dbq.UpsertGameInstallParams{} + installs := []steamInstall{} type key struct { appid string @@ -420,16 +428,19 @@ func discoverSteamInstalls( } seen[k] = struct{}{} - installs = append(installs, dbq.UpsertGameInstallParams{ - StoreID: "steam", - StoreGameID: appid, - InstanceID: instID, - CanonicalGameID: sql.NullString{}, // not used for steam v1 - DisplayName: display, - InstallRoot: installCanon, - Metadata: nullStringFromBytes(metaJSON), - IsPresent: util.SqliteBoolToInt(true), - LastSeenAt: sql.NullString{String: nowISO8601Z(), Valid: true}, // caller sets once per refresh + installs = append(installs, steamInstall{ + params: dbq.UpsertGameInstallParams{ + StoreID: "steam", + StoreGameID: appid, + InstanceID: instID, + CanonicalGameID: sql.NullString{}, + DisplayName: display, + InstallRoot: installCanon, + Metadata: nullStringFromBytes(metaJSON), + IsPresent: util.SqliteBoolToInt(true), + LastSeenAt: sql.NullString{String: nowISO8601Z(), Valid: true}, + }, + steamappsDir: steamapps, }) } } @@ -438,36 +449,41 @@ func discoverSteamInstalls( } func upsertGameDirTarget(ctx context.Context, q *dbq.Queries, gameInstallID int64, installRoot string) error { - const targetName = "game_dir" - - t, err := q.GetTargetByName(ctx, dbq.GetTargetByNameParams{ + _, err := q.UpsertDiscoveredTarget(ctx, dbq.UpsertDiscoveredTargetParams{ GameInstallID: gameInstallID, - Name: targetName, + Name: "game_dir", + RootPath: installRoot, }) if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("get target %s for install_id=%d: %w", targetName, gameInstallID, err) - } - // doesn't exist -> create - return q.UpsertDiscoveredTarget(ctx, dbq.UpsertDiscoveredTargetParams{ - GameInstallID: gameInstallID, - Name: targetName, - RootPath: installRoot, - Metadata: sql.NullString{}, - }) + return fmt.Errorf("upsert game_dir target for install_id=%d: %w", gameInstallID, err) } + return nil +} + +func upsertProtonPrefixTarget(ctx context.Context, q *dbq.Queries, gameInstallID int64, steamappsDir, appid string) error { + const targetName = "proton_prefix" - // don't overwrite if user has specified something manually - if t.Origin == "user_override" { + prefixPath := filepath.Join(steamappsDir, "compatdata", appid, "pfx", "drive_c") + if _, err := os.Stat(prefixPath); err != nil { + if os.IsNotExist(err) { + // No Proton prefix for this game (native Linux or never launched). + return nil + } + // Stat failed for some other reason; non-fatal, skip. return nil } - return q.UpsertDiscoveredTarget(ctx, dbq.UpsertDiscoveredTargetParams{ + canon, err := canonicalizePathBestEffort(prefixPath) + if err != nil { + canon = filepath.Clean(prefixPath) + } + + _, err = q.UpsertDiscoveredTarget(ctx, dbq.UpsertDiscoveredTargetParams{ GameInstallID: gameInstallID, Name: targetName, - RootPath: installRoot, - Metadata: sql.NullString{}, + RootPath: canon, }) + return err } // canonicalizePathBestEffort returns an absolute, cleaned path, attempting to diff --git a/internal/restore/game.go b/internal/restore/game.go index 7ad5a95..4ff14dc 100644 --- a/internal/restore/game.go +++ b/internal/restore/game.go @@ -497,6 +497,7 @@ func importProfileItems(ctx context.Context, dst, src *dbq.Queries, oldGameInsta Enabled: item.Enabled, Priority: item.Priority, RemapConfigID: newRemapConfigID, + TargetID: item.TargetID, Notes: item.Notes, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, diff --git a/migrations/00023_add_target_to_profile_items.sql b/migrations/00023_add_target_to_profile_items.sql new file mode 100644 index 0000000..b9b6e03 --- /dev/null +++ b/migrations/00023_add_target_to_profile_items.sql @@ -0,0 +1,96 @@ +-- +goose Up +-- +goose StatementBegin +-- Step 1: create the new table with target_id NOT NULL +CREATE TABLE profile_items_new +-- profile_items: the pinned set of mod file versions within a profile +-- +-- Notes: +-- - v1 uses policy='pinned' and mod_file_version_id is required. +-- - Future: policy can expand to things like 'latest', etc., with migrations. +-- - priority is per-profile (higher wins conflicts). +-- - enabled allows keeping an item in the profile but disabling it temporarily. +( + id INTEGER PRIMARY KEY, + profile_id INTEGER NOT NULL REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE, + + -- we might use this in the future for now just always set it to 'pinned' + policy TEXT NOT NULL DEFAULT 'pinned' CHECK (policy IN ('pinned')), + + -- pinned version + mod_file_version_id INTEGER NOT NULL REFERENCES mod_file_versions(id) ON UPDATE CASCADE ON DELETE RESTRICT, + + target_id INTEGER NOT NULL REFERENCES targets(id) ON UPDATE CASCADE ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT FALSE CHECK (enabled IN (TRUE, FALSE)), + + -- larger numbers = higher priority (wins conflicts) + priority INTEGER NOT NULL DEFAULT 0, + + -- remap rules/configuration for this item + remap_config_id INTEGER REFERENCES remap_configs(id) ON UPDATE CASCADE ON DELETE CASCADE, + + -- optional notes per item + notes TEXT, + + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + + -- prevent duplicates: same version shouldn't appear multiple times in the same profile + UNIQUE(profile_id, mod_file_version_id), + + -- priority is unique per profile + UNIQUE(profile_id, priority) +) STRICT; +-- +goose StatementEnd + +-- +goose StatementBegin +-- Step 2: copy existing rows, backfilling target_id from the game_dir target +INSERT INTO profile_items_new ( + id, profile_id, policy, mod_file_version_id, target_id, + enabled, priority, remap_config_id, notes, created_at, updated_at +) +SELECT + pi.id, + pi.profile_id, + pi.policy, + pi.mod_file_version_id, + t.id AS target_id, + pi.enabled, + pi.priority, + pi.remap_config_id, + pi.notes, + pi.created_at, + pi.updated_at +FROM profile_items pi +JOIN profiles p ON p.id = pi.profile_id +JOIN targets t ON t.game_install_id = p.game_install_id AND t.name = 'game_dir'; +-- +goose StatementEnd + +-- +goose StatementBegin +-- Step 3: drop old table +DROP TABLE profile_items; +-- +goose StatementEnd + +-- +goose StatementBegin +-- Step 4: rename new table into place +ALTER TABLE profile_items_new RENAME TO profile_items; +-- +goose StatementEnd + +-- Step 5: recreate indexes +-- +goose StatementBegin +CREATE INDEX idx_profile_items_profile ON profile_items(profile_id); +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX idx_profile_items_remap_config ON profile_items(remap_config_id); +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX idx_profile_items_profile_priority ON profile_items(profile_id, enabled, priority DESC); +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX idx_profile_items_mfv ON profile_items(mod_file_version_id); +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX idx_profile_items_target ON profile_items(target_id); +-- +goose StatementEnd + +-- +goose Down +SELECT 'TODO: do the rebuild in reverse...'; diff --git a/migrations/00024_set_installed_files_restrict_target.sql b/migrations/00024_set_installed_files_restrict_target.sql new file mode 100644 index 0000000..6474a37 --- /dev/null +++ b/migrations/00024_set_installed_files_restrict_target.sql @@ -0,0 +1,117 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE installed_files_new +-- installed_files: current tool-managed installed state per path +-- +-- One row per (game_install, target, relpath) representing the current +-- content that modctl expects to exist on disk after the last successful apply. +-- +-- Notes: +-- - content_sha256 hashes the actual bytes written to disk (final state). +-- - owner_mod_file_version_id identifies which mod version produced the file. +-- - last_operation_id points at the operation that last wrote/updated this path. +( + id INTEGER PRIMARY KEY, + game_install_id INTEGER NOT NULL REFERENCES game_installs(id) ON UPDATE CASCADE ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES targets(id) ON UPDATE CASCADE ON DELETE RESTRICT, + relpath TEXT NOT NULL CHECK (LENGTH(relpath) > 0), + -- file content identity (lowercase hex sha256) + content_sha256 TEXT NOT NULL CHECK (LENGTH(content_sha256) = 64 AND content_sha256 GLOB '[0-9a-f]*'), + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + -- owner: exactly one of these is set + -- who "owns" this file in the plan (the winner that supplied it) + owner_mod_file_version_id INTEGER REFERENCES mod_file_versions(id) ON UPDATE CASCADE ON DELETE SET NULL, + owner_override_id INTEGER REFERENCES overrides(id) ON UPDATE CASCADE ON DELETE SET NULL, + -- the profile that last applied this file ("last writer wins"; not exclusive + -- ownership -- the same mod file version may exist in multiple profiles) + owner_profile_id INTEGER REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL, + -- operation that last wrote this path + last_operation_id INTEGER REFERENCES operations(id) ON UPDATE CASCADE ON DELETE SET NULL, + installed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + verified_at TEXT, + + -- one canonical row per path + UNIQUE(game_install_id, target_id, relpath) +) STRICT; +-- +goose StatementEnd + +-- +goose StatementBegin +INSERT INTO installed_files_new ( + id, game_install_id, target_id, relpath, content_sha256, size_bytes, + owner_mod_file_version_id, owner_override_id, owner_profile_id, + last_operation_id, installed_at, verified_at +) +SELECT + id, game_install_id, target_id, relpath, content_sha256, size_bytes, + owner_mod_file_version_id, owner_override_id, owner_profile_id, + last_operation_id, installed_at, verified_at +FROM installed_files; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE installed_files; +-- +goose StatementEnd + +-- +goose StatementBegin +ALTER TABLE installed_files_new RENAME TO installed_files; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_game ON installed_files(game_install_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_target ON installed_files(target_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_owner ON installed_files(owner_mod_file_version_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_owner_override ON installed_files(owner_override_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_owner_profile ON installed_files(owner_profile_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_installed_files_operation ON installed_files(last_operation_id); +-- +goose StatementEnd + +-- +goose StatementBegin +-- Enforce relational consistency between game_install_id and target_id (and profile_id) +-- This prevents bugs where a row references a target from a different game_install. +-- installed_files: ensure target_id belongs to game_install_id +CREATE TRIGGER trg_installed_files_target_matches_install_ins +BEFORE INSERT ON installed_files +FOR EACH ROW +BEGIN + SELECT + CASE + WHEN (SELECT 1 FROM targets t + WHERE t.id = NEW.target_id + AND t.game_install_id = NEW.game_install_id) IS NULL + THEN RAISE(ABORT, 'installed_files: target_id does not belong to game_install_id') + END; +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER trg_installed_files_target_matches_install_upd +BEFORE UPDATE OF target_id, game_install_id ON installed_files +FOR EACH ROW +BEGIN + SELECT + CASE + WHEN (SELECT 1 FROM targets t + WHERE t.id = NEW.target_id + AND t.game_install_id = NEW.game_install_id) IS NULL + THEN RAISE(ABORT, 'installed_files: target_id does not belong to game_install_id') + END; +END; +-- +goose StatementEnd + +-- +goose Down +SELECT 'TODO: do the rebuild dance'; diff --git a/modctl.1.scd b/modctl.1.scd index b99ab76..2bacc36 100644 --- a/modctl.1.scd +++ b/modctl.1.scd @@ -22,8 +22,11 @@ version. modctl discovers games automatically by reading the store's library configuration; no manual path entry is required. *Games* are discovered game installations. Each game has one or more targets: -named locations where mods can be installed. In the current version the only -target is the game's installation directory. +named locations where mods can be installed. The game's installation directory +is always available as the _game_dir_ target. For games that run under Proton, +modctl also creates a _proton_prefix_ target pointing at the Wine C: drive root +inside the Proton prefix. Additional targets can be defined with +*modctl games targets add*. *Mods* are organised in three levels: a mod page (a mod project), a mod file (a downloadable file under a mod page), and a mod file version (a specific @@ -111,6 +114,21 @@ Set the active game: Set the active game install used by subsequent commands. Accepts a numeric ID, a selector, or a game title. +*modctl games targets list* + List all install targets for the current game. + +*modctl games targets add* [--relative-to ] + Add a user-defined install target. The path must be absolute unless + \--relative-to is specified, in which case it is resolved relative to + the named target's root path at creation time. The resolved absolute + path is stored permanently; the base target is not tracked after that + point. + +*modctl games targets remove* + Remove a user-defined install target. Refuses if any files are + currently installed to that target (unapply first). Cannot remove + auto-discovered targets such as _game_dir_ or _proton_prefix_. + ## Stores *modctl stores list* @@ -196,9 +214,10 @@ Set the active game: Compare two profiles. The comparison is directional: profile-a is the source, profile-b is the target. -*modctl profiles add* [--priority ] [--disabled] +*modctl profiles add* [--priority ] [--disabled] [--target ] Add a mod to the active profile. Assigned the next highest priority by - default. + default. Use --target to specify which install target the mod deploys + to (default: _game_dir_). *modctl profiles remove* Remove a mod from the active profile. diff --git a/queries.sql b/queries.sql index 4154331..0d66a19 100644 --- a/queries.sql +++ b/queries.sql @@ -68,32 +68,24 @@ RETURNING id; -- name: GetTargetByName :one SELECT * FROM targets WHERE game_install_id = ? AND name = ? LIMIT 1; --- name: UpsertDiscoveredTarget :exec +-- name: UpsertDiscoveredTarget :one +-- if a user_override exists for the same (game_install_id, name) the conflict +-- is silently ignored and the user's row is left untouched INSERT INTO targets ( - game_install_id, - name, - root_path, - origin, - metadata, + game_install_id, name, root_path, origin, created_at, updated_at ) VALUES ( - ?, -- game_install_id - ?, -- name (e.g. 'game_dir') - ?, -- root_path (canonical) - 'discovered', - ?, -- metadata (nullable) + ?, ?, ?, 'discovered', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ) ON CONFLICT (game_install_id, name) DO UPDATE SET - -- IMPORTANT: caller must avoid calling this if origin='user_override' root_path = excluded.root_path, - origin = 'discovered', - metadata = excluded.metadata, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') -RETURNING id; +WHERE targets.origin = 'discovered' +RETURNING *; -- name: EnsureDefaultProfile :exec INSERT INTO profiles ( @@ -353,11 +345,12 @@ INSERT INTO profile_items ( profile_id, policy, mod_file_version_id, + target_id, enabled, priority, remap_config_id, notes -) VALUES (?, 'pinned', ?, ?, ?, NULL, NULL) +) VALUES (?, 'pinned', ?, ?, ?, ?, NULL, NULL) RETURNING id; -- name: ExistsModFileVersion :one @@ -460,7 +453,9 @@ SELECT pi.id AS item_id, pi.priority, pi.enabled, + pi.target_id, pi.notes AS item_notes, + t.name AS target_name, mp.id AS mod_page_id, mp.name AS mod_page_name, mp.source_kind, @@ -480,6 +475,7 @@ SELECT WHERE rr.remap_config_id = pi.remap_config_id ), 0) AS INTEGER) AS remap_rule_count FROM profile_items pi +JOIN targets t ON t.id = pi.target_id JOIN mod_file_versions mfv ON mfv.id = pi.mod_file_version_id JOIN mod_files mf ON mf.id = mfv.mod_file_id JOIN mod_pages mp ON mp.id = mf.mod_page_id @@ -739,11 +735,12 @@ JOIN mod_pages mpb ON mpb.id = mi.mod_page_id_b WHERE mpa.game_install_id = ? ORDER BY mi.created_at DESC; --- name: GetProfileItemForPlanning :many +-- name: GetProfileItemsForPlanning :many SELECT pi.id AS item_id, pi.priority, pi.enabled, + pi.target_id, pi.remap_config_id, mfv.id AS mod_file_version_id, mfv.archive_sha256, @@ -756,6 +753,7 @@ JOIN mod_file_versions mfv ON mfv.id = pi.mod_file_version_id JOIN mod_files mf ON mf.id = mfv.mod_file_id JOIN mod_pages mp ON mp.id = mf.mod_page_id WHERE pi.profile_id = ? + AND pi.target_id = ? AND pi.enabled = TRUE ORDER BY pi.priority DESC; @@ -1195,7 +1193,7 @@ FROM remap_rules WHERE remap_config_id = ?; -- name: ExportGetProfileItemsForGameInstall :many SELECT pi.id, pi.profile_id, pi.policy, pi.mod_file_version_id, - pi.enabled, pi.priority, pi.remap_config_id, pi.notes, + pi.target_id, pi.enabled, pi.priority, pi.remap_config_id, pi.notes, pi.created_at, pi.updated_at FROM profile_items pi JOIN profiles p ON p.id = pi.profile_id @@ -1286,10 +1284,10 @@ VALUES (?, ?, ?, ?, ?, ?, ?); -- name: ExportInsertProfileItem :exec INSERT INTO profile_items (id, profile_id, policy, mod_file_version_id, - enabled, priority, remap_config_id, notes, + target_id, enabled, priority, remap_config_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, - ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?); -- name: ExportInsertProfilePathPolicy :exec @@ -1385,9 +1383,9 @@ RETURNING id; -- name: ImportInsertProfileItem :one INSERT INTO profile_items (profile_id, policy, mod_file_version_id, - enabled, priority, remap_config_id, notes, + target_id, enabled, priority, remap_config_id, notes, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: ImportInsertProfilePathPolicy :one @@ -2090,3 +2088,38 @@ WHERE id = ?; -- name: GetModFileVersionByIDForUpgrade :one SELECT * FROM mod_file_versions WHERE id = ? LIMIT 1; + +-- name: GetTargetByGameInstallAndName :one +SELECT * +FROM targets +WHERE game_install_id = ? + AND name = ?; + +-- name: InsertUserTarget :one +INSERT INTO targets (game_install_id, name, root_path, origin) +VALUES (?, ?, ?, 'user_override') +RETURNING *; + +-- name: UpdateTargetPath :one +-- filters on origin = 'user_override' so it's a no-op if someone tries on an +-- autodiscovered target +UPDATE targets +SET root_path = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE id = ? + AND origin = 'user_override' +RETURNING *; + +-- name: DeleteTarget :exec +DELETE FROM targets +WHERE id = ?; + +-- name: CountInstalledFilesForTarget :one +SELECT CAST(COUNT(*) AS INTEGER) AS count +FROM installed_files +WHERE target_id = ?; + +-- name: CountProfileItemsForTarget :one +SELECT CAST(COUNT(*) AS INTEGER) AS count +FROM profile_items +WHERE target_id = ?;