core

package
v1.27.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrModLocked = errors.New("mod is locked")

ErrModLocked reports an update apply refused because the profile ref is locked (#97). Callers branch with errors.Is.

View Source
var ErrVersionNotFound = errors.New("version not found")

ErrVersionNotFound reports that version->file resolution ran against a source that does carry per-file version info, but no file matched the requested version exactly. Callers branch with errors.Is; the message names the requested version and the distinct versions that ARE available.

Functions

func ApplyProfileOverrides added in v1.1.0

func ApplyProfileOverrides(game *domain.Game, profile *domain.Profile) error

ApplyProfileOverrides writes a profile's configuration overrides to the game install directory. Each key in profile.Overrides is a path relative to game.InstallPath; the value is written as file content. Used on deploy and profile switch so INI tweaks and other overrides are applied. Paths that escape the game install directory (e.g. ../../../etc/passwd) are rejected to prevent abuse.

func CleanChangelog added in v1.14.0

func CleanChangelog(html string) string

func CompareVersions

func CompareVersions(v1, v2 string) int

CompareVersions delegates to domain.CompareVersions. Kept for backward compatibility with existing callers.

func DetectModName added in v0.10.0

func DetectModName(extractedPath, archiveFilename string) string

DetectModName determines a display name for an imported mod. It checks for a single top-level directory in the extracted content, falling back to the archive basename if not found.

func IsNewerVersion

func IsNewerVersion(currentVersion, newVersion string) bool

IsNewerVersion delegates to domain.IsNewerVersion. Kept for backward compatibility with existing callers.

func LockedRefRefusalError added in v1.27.0

func LockedRefRefusalError(mod domain.Mod, profileName string, ref *domain.ModReference) error

LockedRefRefusalError builds the ErrModLocked-wrapping refusal every lock gate returns when mod's profile ref is locked - ApplyUpdate, ApplyRollback, install gating (lockedInstallRefusal / applyInstallBatchMod), and cmd/lmm's mod-edit version gate - factored into one function specifically so the call sites can never drift apart in wording (exported since #146 so cmd/lmm can reuse the exact same refusal instead of hand-copying it; PR #142 Copilot round-4: the prior hand-duplicated version named no source/profile in its remedies, so a user running the refused operation against a non-active profile, or a mod ID that exists under more than one source, would copy-paste a remedy that resolved against the wrong target - the active profile / an ambiguous source - the same "copy-paste acts on the wrong target" class already fixed for verify's sibling-repair warning). Both remedies now carry the mod's actual source (-s) and the profile actually holding the lock (-p) - modCmd's real, registered flags (cmd/lmm/mod.go: `modCmd.PersistentFlags ().StringVarP(&modSource, "source", "s", ...)` / `StringVarP(&modProfile, "profile", "p", ...)`), so a copy-pasted remedy always resolves against the SAME ref this error is actually about, regardless of which profile/source the caller had active.

func OrderByProfile added in v1.14.0

func OrderByProfile(profile *domain.Profile, mods []domain.InstalledMod) []domain.InstalledMod

OrderByProfile returns mods in a stable, deterministic order for multi-mod operations (deploy, plan/apply): mods absent from profile.Mods first - sorted by "SourceID:ID" key (domain.ModKey) for a reproducible tie-break - followed by mods present in profile.Mods, in profile.Mods order. domain.Profile.Mods documents "first = lowest priority" (see its doc comment), so later entries in that order deploy later and win file conflicts; this function preserves that meaning end to end.

profile may be nil - treated as an empty profile, so every mod is "absent" and the whole result is simply sorted by key. Callers with a profile that failed to load (e.g. an unreadable/missing YAML file) use this to stay deterministic without aborting the caller's own operation.

Keys are deduplicated: a mod repeated in profile.Mods (which shouldn't normally happen - ReorderMods already dedupes on save) or in mods contributes only its single occurrence to the result, at its first resolved position.

func ResolveVersionFiles added in v1.25.0

func ResolveVersionFiles(sourceID string, files []domain.DownloadableFile, version string) ([]domain.DownloadableFile, error)

ResolveVersionFiles selects the files whose Version exactly matches version, from a source's raw (unfiltered) file list - archived/old/deleted files are eligible by design, since a version pin usually targets one (#96). Matches are returned category-sorted (MAIN first, mirroring filterAndSortInstallFiles' ordering) so callers can apply their own sub-selection (--file, the primary heuristic).

Degradation is dynamic rather than capability-driven: a list in which no file carries a non-empty Version cannot resolve any version, and returns source.ErrNotSupported wrapped with the sourceID - the same contract as a source that lacks the operation entirely (#130's vacuous-version precedent: no version info means nothing to compare, not a mismatch).

func UpdateCheckable added in v1.16.0

func UpdateCheckable(mod domain.InstalledMod) bool

UpdateCheckable reports whether CheckUpdates will query a source for mod. Two reasons it will not: the mod is pinned (a user choice, reversible with `lmm mod set-update`), or it is a local import with no remote to ask.

Exported because both interfaces need to explain the gap between "mods installed" and "mods checked" - without it, a filtered mod silently vanishes from update output and the user is told everything is up to date. Callers must use this rather than re-testing the fields, so the reported counts can never drift from what CheckUpdates actually skipped.

Types

type AggregateSearchResult added in v1.10.0

type AggregateSearchResult struct {
	Mods       []domain.Mod    // merged, ranked; each Mod carries its SourceID
	TotalCount int             // sum of per-source totals (sources reporting 0/unknown contribute 0)
	Warnings   []SourceWarning // per-source failures (design §5: warnings, not errors)
	// Exhausted reports whether every source that successfully returned a
	// result for THIS page has nothing left to page through (#58 item 1).
	// TotalCount is summed across sources with INDEPENDENT per-source
	// pagination cursors, so a caller cannot derive "is there a next page"
	// from TotalCount and a single global PageSize the way single-source
	// search can (see internal/tui/search.go's hasNextPage): 3 sources whose
	// entire 10-mod catalog fits on page 0 sum to a TotalCount of 30, which
	// against a pageSize of 10 falsely implies 3 pages exist, when actually
	// every source already returned everything it has. Exhausted applies
	// hasNextPage's own per-source heuristic (TotalCount-bounded when a
	// source reports one, else "a short page means no more") to EACH
	// contributing source and ANDs the results, so it is the accurate signal
	// callers should gate a next-page offer on instead. True when there were
	// zero successful sources too (nothing left to page through).
	Exhausted bool
	// AttemptedCount is how many of the game's configured sources actually
	// had a search attempted against them - capability-less sources are
	// skipped silently (see SearchAllSources's doc comment) and never
	// counted here. Zero means NONE of the game's sources support searching
	// at all, which is indistinguishable from a genuine zero-result search
	// unless a caller checks this field - the honesty-notice fix (#58 item
	// 3): CLI/TUI render a distinct "no source supports search" notice
	// instead of a plain "no mods found" when this is 0.
	AttemptedCount int
}

AggregateSearchResult is the merged outcome of searching every source configured for a game.

type BatchOptions added in v0.12.0

type BatchOptions struct {
	Hooks       *ResolvedHooks // Hooks to run during batch operation
	HookRunner  *HookRunner    // Runner for executing hooks
	HookContext HookContext    // Base context for hooks (mod-specific fields added per-mod)
	Force       bool           // If true, bypass before_* hook failures
}

BatchOptions configures batch install/uninstall operations

type BatchResult added in v0.12.0

type BatchResult struct {
	Installed   []InstalledModResult   // Successfully installed mods (for InstallBatch)
	Uninstalled []UninstalledModResult // Successfully uninstalled mods (for UninstallBatch)
	Skipped     []SkippedMod           // Mods skipped due to hook failure or error
	Errors      []error                // Non-fatal errors (after_* hook failures)
}

BatchResult contains the results of a batch install/uninstall operation

type Conflict added in v0.9.0

type Conflict struct {
	RelativePath    string
	CurrentSourceID string
	CurrentModID    string
}

Conflict represents a file that would be overwritten by installing a mod

type ConflictModRef added in v1.14.0

type ConflictModRef struct{ Key, Name string }

ConflictModRef identifies one mod participating in a file conflict. Key is domain.ModKey ("sourceID:modID"); Name is the mod's display name, falling back to Key when no installed record supplies one.

type DependencyResolver

type DependencyResolver struct{}

DependencyResolver resolves mod dependencies and detects cycles

func NewDependencyResolver

func NewDependencyResolver() *DependencyResolver

NewDependencyResolver creates a new dependency resolver

func (*DependencyResolver) GetDependencyTree

func (r *DependencyResolver) GetDependencyTree(mod *domain.Mod, modMap map[string]*domain.Mod) ([]domain.Mod, error)

GetDependencyTree returns all dependencies for a mod (including transitive)

func (*DependencyResolver) Resolve

func (r *DependencyResolver) Resolve(mods []domain.Mod) ([]domain.Mod, error)

Resolve returns mods in dependency order (dependencies first) Returns ErrDependencyLoop if a circular dependency is detected

func (*DependencyResolver) ValidateDependencies

func (r *DependencyResolver) ValidateDependencies(mods []domain.Mod) error

ValidateDependencies checks if all dependencies are satisfied

type DeployOptions added in v1.11.0

type DeployOptions struct {
	Purge bool // --purge: undeploy every installed mod (regardless of ModID/All) before deploying, remembering which were enabled beforehand for the profile-wide selection below.

	// LinkMethod overrides the link method used for this deploy (--method).
	// nil (the zero value) means "use the profile's effective link method" via
	// Service.GetEffectiveLinkMethod. A pointer is used, rather than a bare
	// domain.LinkMethod with its zero value as the "unset" sentinel, because
	// domain.LinkMethod's zero value (LinkSymlink) is itself a valid,
	// explicit choice - it cannot double as "no override" without losing
	// the ability to explicitly request symlink. See the task report.
	LinkMethod *domain.LinkMethod

	// ModID/SourceID restrict the deploy to a single mod (`lmm deploy
	// <mod-id>`). Both empty (the default) deploys every mod in profile
	// order, subject to All. SourceID selects which source's copy of ModID
	// to deploy - the CLI's --source flag, resolved dynamically since
	// v1.22.0 (sole configured source, or an interactive prompt).
	ModID    string
	SourceID string

	All bool // --all: include disabled mods in a full-profile deploy, or allow deploying a disabled ModID.

	// Hook plumbing, mirroring UninstallOptions. Hooks and/or HookRunner may
	// be nil to skip hook execution entirely (e.g. --no-hooks). The deploy
	// pass runs install.* hooks; the purge pass (when Purge is set) runs
	// uninstall.* hooks, matching the pre-extraction CLI's doDeploy/
	// purgeDeployedMods split.
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	Force       bool // continue past a failing before_* hook (warn instead of fail)
}

DeployOptions configures DeployProfile.

type DeployPhase added in v1.11.0

type DeployPhase int

DeployPhase identifies what DeployProfile is doing for the mod named in a DeployProgress event (or, for DeployPurging, for the purge pass as a whole), letting callers (CLI, TUI) render phase-appropriate UI without needing to know how a deploy is actually carried out.

const (
	// DeployPurging fires once, before any purge-phase mod is touched -
	// from a deploy --purge pass or from PurgeProfile (#61) - when there
	// is at least one installed mod to purge. Total is the number of mods
	// being purged; Index and ModName are zero/empty.
	DeployPurging DeployPhase = iota
	// DeployBeforeEachSkipped: install.before_each failed for ModName: the
	// mod is skipped (added to DeployResult.Skipped). Detail is the reason.
	DeployBeforeEachSkipped
	// DeployRedownloading: ModName's cache entry is missing; DeployProfile
	// is re-fetching it from source.
	DeployRedownloading
	// DeployDownloading: a file for ModName is downloading. Percent is the
	// 0-100 completion (only reported once the source declares a total
	// size, matching the pre-extraction CLI's progress callback gating).
	DeployDownloading
	// DeployDownloadFailed: a file for ModName failed to download; the mod
	// is skipped. Detail is the reason.
	DeployDownloadFailed
	// DeployDownloadDone fires once, after a cache-miss mod's redownload
	// loop finishes without error, mirroring the pre-extraction CLI's
	// unconditional `fmt.Println() // Clear progress line` immediately
	// after the download loop (git show b2ad559:cmd/lmm/deploy.go) - it
	// terminates DeployDownloading's carriage-returned progress line with a
	// real newline before the mod's own DeployDeployed line prints. Unlike
	// its ApplyProfileSwitch analog (SwitchDownloadDone), which fires on
	// both success and failure since doProfileSwitch's equivalent Println
	// sat unconditionally after its own loop, redeployFromSource's failure
	// path returns immediately via a DeployDownloadFailed event instead (see
	// below) without reaching this point - so this phase covers the
	// success path only.
	DeployDownloadDone
	// DeploySkipped: ModName was skipped for a reason other than a hook or
	// download failure (fetch failure, no files available, file-selection
	// failure, or an outright deploy/install failure). Detail is the reason.
	DeploySkipped
	// DeployDeployed: ModName was (re)deployed successfully.
	DeployDeployed

	// DeployBeforeAllForced fires once, immediately, when install.before_all
	// (a deploy) or uninstall.before_all (a --purge pass) fails and Force is
	// set: the pre-extraction CLI printed this warning as the very first
	// line of output, before anything else (the "Purging..."/"Deploying..."
	// header included) - so this event always precedes DeployPurging and
	// any other event. No mod is in scope (Index/Total/ModName/ModID are
	// zero); Detail matches the DeployResult.Warnings entry verbatim.
	DeployBeforeAllForced
	// DeployNote fires wherever DeployProfile appends an entry to
	// DeployResult.Notes for a specific mod during the main deploy loop
	// (a failed undeploy-before-redeploy, a failed SetModLinkMethod, or a
	// failed SetModDeployed), at the exact point it happens - always
	// before that same mod's own DeployDeployed event, matching the
	// pre-extraction CLI's inline ordering. ModName/ModID identify the
	// mod; for the latter two diagnostics, whose historical text carries
	// no mod identity at all, the event's ModName/ModID are the ONLY way
	// to attribute the diagnostic to a mod.
	DeployNote
	// DeployWarning fires wherever DeployProfile appends an entry to
	// DeployResult.Warnings other than a DeployBeforeAllForced one: a
	// failed install.after_each hook (ModName/ModID set), a failed
	// install.after_all hook, or a failed ApplyProfileOverrides (neither
	// has a mod in scope). The pre-extraction CLI printed the overrides
	// warning immediately once computed, then its batched hook warnings
	// (after_each in mod order, then after_all) right after - so
	// DeployProfile emits the overrides DeployWarning (if any) first, then
	// the after_each/after_all ones, reproducing that print order without
	// changing when each check actually runs (see DeployProfile's body).
	DeployWarning
	// PurgeWarning fires wherever a purge appends an entry to its
	// result's Warnings (DeployResult for deploy --purge, PurgeResult for
	// PurgeProfile): a skipped uninstall.before_each mod (deploy mode
	// only - PurgeProfile reports that skip as PurgeModSkipped instead;
	// fires inline, per mod, as it happens), or a failed
	// uninstall.after_each/after_all hook (fires after the whole purge
	// loop has finished, in mod order then after_all - mirroring the
	// pre-extraction CLIs, which accumulated these and printed them
	// together, after every per-mod line, via printHookWarnings).
	PurgeWarning
	// PurgeNote fires wherever a purge appends a per-mod entry to its
	// result's Notes (a failed undeploy, a failed SetModDeployed(false),
	// or PurgeProfile --uninstall's record-delete/profile-remove
	// failures), inline, immediately after that operation - mirroring the
	// pre-extraction CLIs' --verbose-gated "⚠ "/"Note: " lines.
	PurgeNote
	// PurgeComplete fires once, after a non-empty purge has finished
	// everything (including its own hook warnings) - before DeployProfile
	// moves on to gathering mods to deploy, or as PurgeProfile's terminal
	// event. It carries no data; a deploy --purge caller wanting
	// byte-identical pre-extraction output prints exactly one blank line
	// here - purgeDeployedMods's own final `fmt.Println()`, which the
	// initial extraction had misplaced immediately after the purge header
	// instead of at the end of the purge phase (`lmm purge` prints
	// nothing for it).
	PurgeComplete

	// SwitchDisableNote fires for each of the disable loop's two possible
	// per-mod diagnostics (a failed Uninstall, then a failed SetModEnabled),
	// mirroring doProfileSwitch's "  Warning: failed to undeploy %s: %v" /
	// "  Warning: failed to update %s: %v" - both --verbose-gated stdout
	// prints. Detail carries the historical "Warning: " prefix baked in; a
	// caller wanting byte-identical output prints
	// `if verbose { fmt.Printf("  %s\n", p.Detail) }`.
	SwitchDisableNote
	// SwitchDisabled fires once a mod's disable step has finished
	// (regardless of whether SwitchDisableNote fired for it) -
	// doProfileSwitch always disables the DB row and always prints
	// "  ✓ Disabled: %s" even when the undeploy/DB update above it failed.
	// ModName is set.
	SwitchDisabled
	// SwitchEnableNote mirrors SwitchDisableNote for the enable loop's two
	// diagnostics (a failed Install, then a failed SetModEnabled). Unlike
	// the disable loop, a failed Install is fatal FOR THAT MOD ONLY: the mod
	// is skipped (no SwitchEnabled event follows) - see doProfileSwitch's
	// `continue` after the Install failure branch.
	SwitchEnableNote
	// SwitchEnabled fires once a mod has been successfully deployed (and
	// enabled, or deployed but its SetModEnabled bookkeeping failed - see
	// SwitchEnableNote), mirroring "  ✓ Enabled: %s".
	SwitchEnabled
	// SwitchInstalling fires once, before the install loop, only when there
	// is at least one mod to install (Total = len(SwitchPlan.ToInstall)),
	// mirroring doProfileSwitch's "\nInstalling missing mods...".
	SwitchInstalling
	// SwitchInstallingMod fires once per mod to install, before it is even
	// fetched - SourceID/ModID are the only identity available at this
	// point, mirroring "  Installing %s:%s...".
	SwitchInstallingMod
	// SwitchInstallError fires for any of the install loop's mod-fatal-only
	// failure reasons (fetch, get-files, no-files, file-selection, deploy,
	// or save), each already worded to match its historical text exactly
	// (Detail is printed verbatim as "    Error: %s"). Unlike
	// DeployProfile's DeploySkipped, these are NOT accumulated into any
	// SwitchResult slice - doProfileSwitch never printed a final
	// skipped-count summary for profile switch, so there is nothing to
	// accumulate beyond the live event.
	SwitchInstallError
	// SwitchDownloading mirrors DeployDownloading for the install loop's
	// download progress (Percent set, gated the same way: only once the
	// source declares a total size).
	SwitchDownloading
	// SwitchDownloadFailed fires when a file download fails; Detail is
	// "download failed: %v". A caller wanting byte-identical output prints a
	// blank line then "    Error: %s" with Detail - see SwitchDownloadDone's
	// doc comment for why the blank line isn't included here.
	SwitchDownloadFailed
	// SwitchDownloadDone fires once per install-loop mod after its download
	// loop finishes, on both success and failure - doProfileSwitch's
	// `fmt.Println()` after the loop runs unconditionally either way. When
	// the #96 cache-first guard skips the download entirely, this phase is
	// skipped with it (there is no download readout to terminate). A
	// caller wanting byte-identical output prints a bare blank line here;
	// combined with SwitchDownloadFailed's own leading blank line, a failed
	// download reproduces the original's blank/error/blank sequence, and a
	// successful one reproduces its single trailing blank line.
	SwitchDownloadDone
	// SwitchInstalled fires once a to-be-installed mod has been fetched,
	// downloaded, deployed, and saved to the DB, mirroring "    ✓ Installed:
	// %s". ModName is set (mod.Name, now known).
	SwitchInstalled
	// SwitchInstallNote fires when UpsertMod (recording the profile's
	// FileIDs) fails after a successful install - the sole --verbose-gated
	// diagnostic in the install loop, mirroring "    Warning: could not
	// update profile: %v" (4-space indent, one level deeper than
	// SwitchDisableNote/SwitchEnableNote's 2-space Notes).
	SwitchInstallNote

	// --- Phase 5b Task 2: ApplyInstall progress events, restored to
	// byte-for-byte per-path fidelity in Fix wave 1 (see
	// task-2-report.md's "Fix wave 1 (dep-path fidelity)" entry for the full
	// review trace). ApplyInstall reproduces the pre-extraction CLI's own
	// TWO divergent execution engines EXACTLY, gated on
	// len(plan.Dependencies):
	//
	//   - Empty (the STRICT/no-deps path): the primary uses doInstall's own
	//     single-mod code unchanged from Task 2 - Force-gated
	//     before_all/before_each, Install-or-Replace (incl. the
	//     reinstall-cache-transaction for a same-version reinstall),
	//     interactive/--file file selection and the blocking
	//     conflict-confirm prompt are the CALLER's job (plan.Files/
	//     plan.Conflicts), SaveFileChecksum, --skip-verify. See
	//     InstallDownload*/InstallChecksumComputed/InstallExtracting/
	//     InstallDeploying/InstallDone below.
	//   - Non-empty (the BATCH path): EVERY mod in [Dependencies...,
	//     primary] uses batchInstallMods' lenient mechanics IDENTICALLY -
	//     the primary is NOT special-cased at all here, matching the
	//     pre-extraction CLI's own behavior of delegating the WHOLE list,
	//     target included, to batchInstallMods whenever there were
	//     dependencies to install (doInstall's "if len(modsToInstall) > 1"
	//     early return, before any single-mod code - including file
	//     selection and the conflict prompt - ever ran). before_each is
	//     NEVER Force-gated (a failure always just skips that one mod and
	//     continues, primary included), no Replace path (always a fresh
	//     Install; a same-key existing mod is uninstalled+cache-deleted
	//     first), no interactive file selection (always the
	//     primary-or-first file, re-resolved per mod - plan.Files is never
	//     consulted), conflicts are a non-blocking inline warning (never a
	//     prompt). See InstallDepInstalling below onward.
	InstallBeforeAllForced

	// InstallBeforeEachForced fires when the PRIMARY mod's install.before_each
	// hook fails and Force is set (a forced warning, not a fatal error) -
	// mirrors doInstall's own before_each Force-gate exactly. ModName/ModID
	// identify the primary. ONLY fires in the STRICT (no-deps) path - in the
	// BATCH path the primary's before_each is never Force-gated at all (see
	// InstallDepSkipped), matching batchInstallMods exactly.
	InstallBeforeEachForced

	// InstallDepInstalling fires once per mod in the BATCH path's combined
	// [Dependencies..., primary] list - dependency OR primary alike -
	// before before_each even runs, mirroring batchInstallMods' own
	// "\n[%d/%d] Installing: %s v%s\n" byte-for-byte (Fix wave 1 restored
	// the exact text and the primary's participation; Task 2's original
	// design fired this for dependencies only, with different wording -
	// see task-2-report.md). Index/Total count across the WHOLE combined
	// list (len(plan.Dependencies)+1), matching batchInstallMods' shared
	// counter; ModVersion carries the version for the restored "v%s" text.
	InstallDepInstalling
	// InstallDepReinstalling fires, unconditionally (not verbose-gated),
	// when a BATCH-path mod (dependency or primary) already has an existing
	// installed row for (SourceID, ID, Profile) - mirroring
	// batchInstallMods' unconditional "  Removing previous installation...".
	// The existing install is then uninstalled and its cache entry deleted
	// - never a Replace/reinstall-cache-transaction (that mechanism is
	// STRICT-path only).
	InstallDepReinstalling
	// InstallDepFileSelected fires once a BATCH-path mod's downloadable
	// files have been fetched, filtered/sorted, and reduced to the
	// primary-or-first file (never interactive, never --file) - mirroring
	// batchInstallMods' "  File: %s\n". File identifies which, for the
	// CLI's own displayFileLabel call.
	InstallDepFileSelected
	// InstallDepDownloading mirrors batchInstallMods' per-mod download
	// progress readout (Percent only, gated on a known total size - no
	// byte-count fallback line, unlike the STRICT path's
	// InstallDownloading). Fires for a dependency OR the primary alike.
	InstallDepDownloading
	// InstallDepSkipped fires whenever ANY BATCH-path mod (dependency or
	// primary alike) is skipped for any reason (hook failure, fetch/files/
	// download/deploy/save failure) - unconditional, never Force-gated,
	// matching batchInstallMods exactly. Detail already carries the
	// restored, failure-type-specific, fully-prefixed line text verbatim
	// ("Skipped: install.before_each hook failed: %v" for a hook failure;
	// "Error: <reason>" for every other failure type - batchInstallMods
	// used different wording per failure type, never a uniform "Skipped:
	// <name>: <reason>" - see task-2-report.md's Fix wave 1 for the
	// before/after); a caller wanting byte-identical output prints
	// `fmt.Printf("  %s\n", p.Detail)`. Index/Total count across the whole
	// combined list, matching InstallDepInstalling.
	InstallDepSkipped
	// InstallDepDownloadDone fires, unconditionally (success OR failure
	// alike), immediately after a BATCH-path mod's DownloadMod call
	// returns - mirroring batchInstallMods' unconditional `fmt.Println()`
	// right after the download call, which precedes InstallDepSkipped's
	// own restored "\n  Error: download failed: %v\n" leading blank line
	// on failure. A caller wanting byte-identical output prints a bare
	// `fmt.Println()` here.
	InstallDepDownloadDone
	// InstallDepConflictWarning fires when a BATCH-path mod's files
	// (already downloaded/cached at this point) would overwrite files from
	// another installed mod and Force is NOT set - a non-blocking,
	// informational warning only (batchInstallMods never prompts in the
	// BATCH path, primary included - the blocking plan.Conflicts prompt is
	// STRICT-path only). Detail is "%d file conflict(s) - will overwrite".
	InstallDepConflictWarning
	// InstallDepInstalled fires once a BATCH-path mod (dependency or
	// primary) has been fully installed (downloaded, deployed, saved,
	// profile-upserted) - mirroring batchInstallMods' restored
	// "  ✓ Installed (%d files)\n" (Fix wave 1: Task 2's original design
	// used the mod's name instead of its file count - see
	// task-2-report.md). FilesExtracted carries the count.
	InstallDepInstalled

	// InstallDownloadStarted fires once per one of the PRIMARY's selected
	// files (plan.Files) in the STRICT (no-deps) path only, before it
	// begins downloading - mirrors downloadSelectedFiles'
	// "\n[%d/%d] Downloading %s...\n" (or, for a single file,
	// "\nDownloading %s...\n"). File identifies which (for the CLI's own
	// displayFileLabel call); Index/Total count among plan.Files. The BATCH
	// path has no equivalent "starting" event - its download progress
	// begins directly at InstallDepDownloading.
	InstallDownloadStarted
	// InstallDownloading mirrors the STRICT path's primary per-tick
	// download progress - Downloaded/TotalBytes/Percent carry the raw
	// numbers so the CLI can reproduce its exact byte-count/percent
	// readout (see DeployProgress's doc comment on those fields). The
	// BATCH path's per-mod download progress fires InstallDepDownloading
	// instead (Percent only, no byte-count fallback).
	InstallDownloading
	// InstallDownloadDone fires once a STRICT-path file's download attempt
	// finishes - success OR failure alike, mirroring downloadSelectedFiles'
	// `fmt.Println()` that runs unconditionally right after the download
	// call returns, before branching on its error. The BATCH path's
	// equivalent is InstallDepDownloadDone.
	InstallDownloadDone
	// InstallDownloadFailed fires when a STRICT-path (primary) file
	// download fails; Detail carries "download failed: %v" (the CLI checks
	// Detail for the "third-party downloads" substring itself, mirroring
	// doInstall's own check, to print the manual-install notice using the
	// plan's own Mod.SourceURL/ID - already in the CLI's enclosing scope,
	// so it isn't duplicated onto the event). Always fatal - the BATCH
	// path's equivalent (InstallDepSkipped) never is.
	InstallDownloadFailed
	// InstallChecksumComputed fires once a checksum has been computed and
	// !SkipVerify, for BOTH paths: the STRICT path's primary file(s)
	// (Index/Total/File populated, matching InstallDownloadStarted) and
	// the BATCH path's per-mod checksum (Index/Total/ModName populated
	// instead, File unset - mirroring batchInstallMods' own
	// "  Checksum: %s\n", fired once per mod right after its download
	// succeeds). Detail carries the full (untruncated) checksum either
	// way; the CLI applies its own truncateChecksum.
	InstallChecksumComputed
	// InstallExtracting mirrors doInstall's unconditional "Extracting to
	// cache..." status line, fired once after the STRICT-path primary's
	// download(s) finish, before Install/Replace. The BATCH path never
	// prints this (batchInstallMods had no equivalent status line).
	InstallExtracting
	// InstallDeploying mirrors "Deploying to game directory...", fired once
	// right before the STRICT-path primary's Install/Replace. The BATCH
	// path never prints this.
	InstallDeploying
	// InstallDone fires once the STRICT-path primary has been fully
	// installed (deployed, saved, checksum stored, profile upserted). The
	// BATCH path's equivalent (for every mod, primary included) is
	// InstallDepInstalled.
	InstallDone

	// InstallNote fires wherever ApplyInstall appends an entry to
	// InstallResult.Notes (a failed profile-create, UpsertMod,
	// reinstall-cache-transaction commit, old-cache cleanup, or - BATCH
	// path only - a failed Uninstall/cache-Delete while removing a
	// mod's previous installation, see InstallDepReinstalling) - the
	// --verbose-gated stdout bucket, mirroring DeployNote/SwitchInstallNote.
	// Detail equals the Notes entry verbatim; ModName/ModID identify the
	// mod when relevant.
	InstallNote
	// InstallWarning fires wherever ApplyInstall appends an entry to
	// InstallResult.Warnings other than an InstallBeforeAllForced/
	// InstallBeforeEachForced one: a failed SaveFileChecksum (unconditional
	// stderr, matching doInstall exactly - NOT verbose-gated), or an
	// install.after_each/after_all hook failure (deferred - see
	// ApplyInstall's doc comment - emitted after the whole run, mirroring
	// DeployWarning/printHookWarnings' batched timing).
	InstallWarning

	// UpdateDownloading mirrors applyUpdate's own download-progress readout
	// ("\r  Downloading: %.1f%%", verbose-gated in the pre-extraction CLI) -
	// Percent only, gated on a known total size, matching
	// DeployDownloading/InstallDepDownloading's own gating (no raw
	// byte-count fallback - applyUpdate never printed one).
	UpdateDownloading
	// UpdateDownloadDone fires once, only after EVERY file in the update's
	// download step has downloaded successfully - mirroring applyUpdate's
	// own `if verbose { fmt.Println() }`, which terminates the
	// carriage-returned UpdateDownloading progress line. A download failure
	// returns immediately instead (see ApplyUpdate's doc comment), so -
	// like DeployDownloadDone, and unlike InstallDownloadDone - this covers
	// the success path only. A caller wanting byte-identical pre-extraction
	// output prints this ONLY under --verbose (the historical gate lived on
	// the print itself, not just the progress ticks).
	UpdateDownloadDone
	// UpdateBeforeEachForced fires when EITHER of the update's two
	// Force-gated hooks - uninstall.before_each (old version) or
	// install.before_each (new version) - fails with Force set, mirroring
	// applyUpdate's own two, textually-near-identical (only the hook name
	// differs) "Warning: %s hook failed (forced): %v" unconditional stderr
	// prints. Detail already carries the full, hook-specific message
	// verbatim.
	//
	// Reused, extend-don't-fork (Phase 6b Task 5): ApplyRollback fires this
	// SAME phase for its own two Force-gated before_each hooks -
	// uninstall.before_each (the version being rolled back FROM) and
	// install.before_each (the version being rolled back TO) - mirroring
	// doUpdateRollback's own two near-identical Force checks exactly. The
	// two flows are never in progress at once, so the shared phase carries
	// no ambiguity; Detail alone (plus ModName/ModID) tells a caller which
	// hook and which mod failed.
	UpdateBeforeEachForced
	// UpdateWarning fires for either of the update's two after_each hook
	// failures - uninstall.after_each (old version) or install.after_each
	// (new version) - mirroring applyUpdate's own hookErrors/
	// printHookWarnings pair, fired right after both hooks have run
	// (Replace already succeeded), in hook-run order (uninstall.after_each,
	// then install.after_each) - unlike DeployWarning/InstallWarning's
	// end-of-whole-run deferral, since applyUpdate itself prints these
	// immediately, well before its own DB-update steps below.
	//
	// #143 additionally fires this phase for file-SELECTION warnings (a
	// stored file whose version label left it unresolvable - see
	// updateAmbiguousFileWarning). Those come from a pure decision made
	// BEFORE any download, so unlike the hook failures above they can
	// precede every side effect - the phase no longer implies that Replace
	// or the hooks have run. That early emission is deliberate: the fact is
	// already known, and surfacing it up front means the user sees it even
	// if a later download fails (ApplyUpdate's partial-result convention
	// returns accumulated diagnostics alongside the error either way).
	//
	// Reused (Phase 6b Task 5): ApplyRollback fires this SAME phase for its
	// own two always-non-fatal after_each hooks, in the same
	// uninstall-then-install order, mirroring doUpdateRollback's own
	// hookErrors/printHookWarnings pair exactly.
	UpdateWarning
	// UpdateNote fires when SetModLinkMethod fails after a successful
	// update - the sole --verbose-gated diagnostic in applyUpdate,
	// mirroring "  Warning: could not update link method: %v" (2-space
	// indent, prefix baked into Detail, matching SwitchDisableNote/
	// SwitchEnableNote's own convention).
	//
	// Reused (Phase 6b Task 5): ApplyRollback fires this SAME phase for its
	// own SetModLinkMethod failure, mirroring doUpdateRollback's
	// textually-identical verbose-gated print exactly.
	UpdateNote

	// PurgeModSkipped fires when a mod's uninstall.before_each hook fails
	// during `lmm purge`: the mod is skipped entirely (stays deployed) and
	// counts toward PurgeResult.Skipped. Index/Total/ModName/ModID are set;
	// Detail carries "uninstall.before_each hook failed: <err>" - the text
	// doPurge printed after "  Skipped <name>: " (the matching Skipped
	// entry is the same Detail behind a "<name>: " prefix). Contrast with
	// deploy --purge, which reports the equivalent skip as a PurgeWarning.
	PurgeModSkipped
	// PurgeModPurged fires when a mod finishes purging - at doPurge's
	// "  ✓ <name>"/succeeded++ point, after that mod's uninstall.after_each
	// attempt. Index/Total/ModName/ModID are set. Note a best-effort
	// undeploy or SetModDeployed failure (PurgeNote) does NOT suppress
	// this; only a before_each skip or an --uninstall record-delete
	// failure does.
	PurgeModPurged

	// ImportSaved fires once, immediately after the profile is saved
	// (ProfileManager.ImportWithOptions succeeds), mirroring doProfileImport's
	// "\n✓ Imported profile: %s\n". ModName carries the saved profile's name.
	ImportSaved
	// ImportInstalling fires once, only when the install loop is actually
	// about to run (downloads pending, NoInstall unset, and ConfirmInstall -
	// if any - accepted), mirroring "\nDownloading and installing mods...\n".
	// Total is the number of mods about to be attempted (len(toDownload)).
	ImportInstalling
	// ImportModInstalling fires once per mod in the combined
	// [NeedsRedownload..., Missing...] download list, before it is even
	// fetched - mirroring "  Installing %s:%s...\n". SourceID/ModID are the
	// only identity available at this point (ModName is set once the mod is
	// fetched, for every LATER event concerning this same ref); Index/Total
	// count across the whole combined list, matching ApplyProfileSwitch's
	// SwitchInstallingMod.
	ImportModInstalling
	// ImportDownloading mirrors the per-mod download-progress readout ("\r
	// Downloading: %.1f%%") - Percent only, gated on a known total size,
	// matching every other flow's own gating.
	ImportDownloading
	// ImportDownloadDone fires once per mod whose download loop actually
	// ran (success OR failure alike), immediately after that loop finishes
	// - mirroring doProfileImport's own unconditional `fmt.Println()` right
	// after the download loop, which precedes ImportModFailed's own leading
	// blank line on failure (see ImportModFailed). When #138's cache-first
	// guard skips the download entirely (target version already fully
	// marked in cache), this phase is skipped with it - the same shape as
	// SwitchDownloadDone under ApplyProfileSwitch's #96 guard - so there is
	// no download readout to terminate. A caller wanting byte-identical
	// output prints a bare `fmt.Println()` here.
	ImportDownloadDone
	// ImportModFailed fires for ANY of the download loop's mod-skipping
	// failure reasons - a failed GetMod, GetModFiles, an empty file list, a
	// file-selection error, a failed DownloadMod, a failed installer.Install,
	// or a failed SaveInstalledMod - mirroring doProfileImport's uniform "
	// Error: %s\n" (Detail already carries the reason text verbatim: "failed
	// to fetch mod: %v", "failed to get files: %v", "no downloadable files",
	// the file-selection error's own message, "download failed: %v",
	// "deploy failed: %v", or "save failed: %v"). The download-failure
	// variant is preceded by its own extra blank line in the pre-extraction
	// CLI (printed inside the download loop, before the unconditional
	// ImportDownloadDone one after it) - a caller wanting byte-identical
	// output detects this the same way InstallDownloadFailed's own doc
	// comment describes (checking Detail's text, here for a
	// "download failed:" prefix) and prints a bare blank line first. Always
	// non-fatal - the loop always continues to the next ref, matching
	// failedCount++; continue.
	ImportModFailed
	// ImportModInstalled fires once a to-be-installed mod has been fully
	// installed (downloaded, deployed, saved, profile-upserted) - mirroring
	// "    ✓ Installed: %s\n". ModName is set (mod.Name, now known).
	ImportModInstalled
	// ImportNote fires when UpsertMod (recording the profile's FileIDs after
	// a successful install) fails - the sole --verbose-gated diagnostic in
	// the install loop, mirroring "    Warning: could not update profile: %v"
	// (4-space indent, matching ApplyProfileSwitch's own SwitchInstallNote
	// convention).
	ImportNote
)

type DeployProgress added in v1.11.0

type DeployProgress struct {
	Index, Total int
	ModName      string
	ModID        string
	// SourceID is populated by ApplyProfileSwitch's install-loop events
	// (SwitchInstallingMod onward), where a mod's SourceID:ModID pair is the
	// only identity known before it has even been fetched (no ModName yet).
	// DeployProfile's own events never set it (zero value, ignored).
	SourceID string
	Phase    DeployPhase
	Detail   string
	Percent  float64

	// Downloaded and TotalBytes carry the raw byte counts behind Percent for
	// the primary mod's own download phases (InstallDownloading) - unlike
	// DeployDownloading (gated on TotalBytes > 0 and Percent-only),
	// doInstall's downloadSelectedFiles prints a byte-count readout even
	// when the total size is unknown ("Downloaded %s" vs "%.1f%% (%s /
	// %s)"), so the CLI needs the raw numbers, not just Percent, to
	// reproduce that byte-identically. Zero for every other phase.
	Downloaded int64
	TotalBytes int64
	// File identifies which of the primary mod's selected files an
	// InstallDownload* event concerns, so the CLI can call its own
	// displayFileLabel(*File) to reproduce doInstall's exact file-name
	// formatting without core duplicating that cosmetic, CLI-only helper.
	// Populated only for InstallDownloadStarted/InstallDownloading/
	// InstallDownloadDone/InstallDownloadFailed/InstallChecksumComputed.
	File *domain.DownloadableFile

	// ModVersion carries the mod's version for InstallDepInstalling's
	// restored "Installing: %s v%s" header text (batchInstallMods printed
	// the version; the strict/no-deps path's own headers are printed
	// CLI-side from data already in doInstall's scope, so this is a
	// batch-only field). Zero for every other phase.
	ModVersion string
	// FilesExtracted carries the batch-mode mod's own extracted-file count
	// for InstallDepInstalled's restored "  ✓ Installed (%d files)" text
	// (batchInstallMods' downloadResult.FilesExtracted) - distinct from
	// InstallResult.FilesDeployed, which only ever tracks the STRICT path's
	// primary. Zero for every other phase.
	FilesExtracted int
}

DeployProgress reports incremental status during DeployProfile. Index and Total describe ModName's position among the mods being deployed (both zero for phases with no mod/count in scope - see each DeployPhase constant's doc comment). ModID accompanies ModName wherever a specific mod is in scope; for phases whose historical text carries no mod name at all (DeployNote's link-method/mark-deployed cases), it is the only attribution available. Detail and Percent are populated only for the phases documented on DeployPhase's constants; both are zero otherwise.

type DeployResult added in v1.11.0

type DeployResult struct {
	Deployed int
	Skipped  []string
	Warnings []string
	Notes    []string
}

DeployResult reports the outcome of DeployProfile. As with UninstallResult (see its doc comment), every entry below is always recorded - there is no verbosity concept in core - but Warnings and Notes carry the same two display contracts Task 2 established:

  • Warnings holds diagnostics the pre-extraction CLI printed unconditionally to stderr: install.before_all/uninstall.before_all (when forced), a skipped uninstall.before_each during purge, install/uninstall after_each/after_all hook failures, and a profile-overrides application failure. Callers should print each entry to stderr, unconditionally, e.g. `fmt.Fprintf(os.Stderr, "Warning: %v\n", w)`.
  • Notes holds operational diagnostics the pre-extraction CLI only printed under --verbose: a failed undeploy-before-redeploy, a failed SetModLinkMethod, and a failed SetModDeployed, all per mod, plus (for a --purge pass) the equivalent per-mod undeploy/SetModDeployed failures from purging. Each entry already carries its historical prefix ("Warning: " for the deploy-loop trio, "⚠ " for the purge trio) baked into the text, matching each one's pre-extraction wording; a caller wanting byte-identical pre-extraction output should print each entry to stdout ONLY under --verbose, verbatim, e.g. `fmt.Printf(" %s\n", n)`.

Every entry in both slices is ALSO reported via the progress callback at the exact point it is appended (DeployBeforeAllForced/DeployNote/ DeployWarning/PurgeWarning/PurgeNote - see each DeployPhase constant's doc comment for which), with Detail equal to the slice entry verbatim and the phase itself indicating which display contract above applies. A caller driving its console output entirely from progress events (as cmd/lmm's doDeploy does) gets pre-extraction-accurate positioning; the slices remain here, unconditionally, for callers that only want the final, order-independent summary.

Skipped carries one "<mod name>: <reason>" entry per mod that did not deploy, for any reason (hook failure, download failure, install failure); the pre-extraction CLI printed each of these unconditionally as it happened; DeployProgress's DeployBeforeEachSkipped/ DeployDownloadFailed/DeploySkipped events carry the same reason text in real time for callers that want to print them as they occur instead of (or in addition to) at the end.

On error, the returned result carries any diagnostics accumulated before the failure; callers should surface them alongside the error.

type DeployedFile added in v1.3.7

type DeployedFile struct {
	SourceID string
	ModID    string
	FileID   string
	Checksum string
}

DeployedFile is a service-boundary view of a tracked mod file with its checksum.

type DisableResult added in v1.12.0

type DisableResult struct {
	Changed bool
	Notes   []string
}

DisableResult reports the outcome of DisableMod. Changed mirrors EnableResult.Changed. Notes carries the sole diagnostic DisableMod can produce — a non-fatal undeploy failure (see DisableMod's doc comment) — using the same historical-prefix-baked-into-the-text convention UninstallResult's doc comment documents: a caller wanting byte-identical pre-5a output should print each entry to stdout ONLY under --verbose, verbatim, e.g. `fmt.Printf(" %s\n", n)`.

type DownloadModResult added in v0.8.0

type DownloadModResult struct {
	FilesExtracted int    // Number of files extracted
	Checksum       string // MD5 hash of downloaded archive
}

DownloadModResult contains the outcome of downloading a mod file

type DownloadProgress

type DownloadProgress struct {
	TotalBytes int64   // Total size in bytes (0 if unknown)
	Downloaded int64   // Bytes downloaded so far
	Percentage float64 // Completion percentage (0-100)
}

DownloadProgress represents the current state of a download

type DownloadResult added in v0.8.0

type DownloadResult struct {
	Path     string // Final file path
	Size     int64  // Bytes downloaded
	Checksum string // MD5 hash of downloaded file (recorded in the DB)
	SHA256   string // SHA-256 of downloaded file (compared against source-declared checksums)
}

DownloadResult contains the outcome of a download

type Downloader

type Downloader struct {
	// contains filtered or unexported fields
}

Downloader handles HTTP file downloads with progress tracking

func NewDownloader

func NewDownloader(httpClient *http.Client) *Downloader

NewDownloader creates a new Downloader with the given HTTP client If httpClient is nil, http.DefaultClient is used

func (*Downloader) Download

func (d *Downloader) Download(ctx context.Context, url, destPath string, progressFn ProgressFunc) (*DownloadResult, error)

Download fetches a file from the URL and saves it to destPath, with retries on transient failures (exponential backoff). Progress updates are sent to the optional progressFn callback.

func (*Downloader) DownloadWithHeaders added in v1.7.0

func (d *Downloader) DownloadWithHeaders(ctx context.Context, url, destPath string, headers map[string]string, progressFn ProgressFunc) (*DownloadResult, error)

DownloadWithHeaders is Download with extra request headers applied to every attempt — used for authenticated file downloads from custom sources.

type EnableResult added in v1.12.0

type EnableResult struct {
	Changed bool
	Notes   []string
}

EnableResult reports the outcome of EnableMod. Changed is true iff the mod was actually deployed and flipped to enabled — false (not an error) when it was already enabled, mirroring EnableMod's pre-Task-6 (bool, error) return. Notes carries operational diagnostics using the same display-contract convention as UninstallResult/DeployResult (Task 2's convention, extended here in Task 6 item a for result-struct convergence): always empty today — EnableMod has no diagnostic-producing step — kept for parity with DisableResult and so a future EnableMod diagnostic wouldn't need another signature change.

type Extractor

type Extractor struct{}

Extractor handles archive extraction for mod files

func NewExtractor

func NewExtractor() *Extractor

NewExtractor creates a new Extractor

func (*Extractor) CanExtract

func (e *Extractor) CanExtract(filename string) bool

CanExtract returns true if the extractor can handle the given filename

func (*Extractor) DetectFormat

func (e *Extractor) DetectFormat(filename string) string

DetectFormat returns the archive format based on filename extension

func (*Extractor) Extract

func (e *Extractor) Extract(archivePath, destDir string) error

Extract extracts an archive to the destination directory Supports .zip (native), .7z and .rar (via system 7z command)

type HookContext added in v0.12.0

type HookContext struct {
	GameID     string
	GamePath   string
	ModPath    string
	ModID      string // Empty for *_all hooks
	ModName    string // Empty for *_all hooks
	ModVersion string // Empty for *_all hooks
	HookName   string // e.g., "install.before_all"
}

HookContext provides environment information for hook scripts

type HookResult added in v0.12.0

type HookResult struct {
	Stdout   string
	Stderr   string
	ExitCode int
}

HookResult contains the output from running a hook

type HookRunner added in v0.12.0

type HookRunner struct {
	// contains filtered or unexported fields
}

HookRunner executes hook scripts with timeout and environment

func NewHookRunner added in v0.12.0

func NewHookRunner(timeout time.Duration) *HookRunner

NewHookRunner creates a new hook runner with the given timeout

func (*HookRunner) Run added in v0.12.0

func (r *HookRunner) Run(ctx context.Context, scriptPath string, hc HookContext) (*HookResult, error)

Run executes a hook script and returns its output

type ImportOptions added in v0.10.0

type ImportOptions struct {
	SourceID    string // Explicit source (empty = auto-detect or "local")
	ModID       string // Explicit mod ID (empty = auto-detect or generate)
	ProfileName string // Target profile
}

ImportOptions configures the import operation

type ImportPlan added in v1.14.0

type ImportPlan struct {
	// Profile is the parsed-but-not-yet-saved profile (ProfileManager.
	// ParseProfile's result) - its Name/Mods drive both the CLI's preview
	// print and ApplyImport's own save step.
	Profile *domain.Profile

	// Installed holds every profile mod already installed (a DB row exists)
	// at the profile's own version (or with no version recorded in the
	// profile at all) AND cached at that exact version - nothing to do for
	// these. NeedsRedownload holds mods that must be re-fetched: a DB row
	// with no matching cache entry (installed somewhere, cache gone), or -
	// #138's convergence case, mirroring PlanProfileSwitch's #96 drift case -
	// a row installed at a DIFFERENT version than the imported profile
	// records, scheduled for reinstall at the profile's version (downgrades
	// included; each such ref also records the row being converged away from
	// in priorVersions). Missing holds mods with no DB row anywhere (checked
	// across EVERY saved profile for the game, not just the one being
	// imported into - doProfileImport's cross-profile scan, :428-438). All
	// three preserve profile.Mods' own order.
	Installed, NeedsRedownload, Missing []domain.ModReference

	// Exists reports whether a profile with this name is already saved for
	// the game - purely informational (e.g. so a caller can warn before even
	// attempting the save); ApplyImport does not consult it, instead letting
	// ProfileManager.ImportWithOptions' own existence check (driven by
	// ProfileImportOptions.Force) produce the authoritative error.
	Exists bool
	// contains filtered or unexported fields
}

ImportPlan is the pure, displayable result of PlanImport: everything the pre-extraction CLI's pre-import preview (doProfileImport :416-478) needs to render before a caller decides whether/how to proceed (ApplyImport then actually executes one of these). Computed with zero side effects - it parses the given data and inspects existing DB/cache/profile state, but never writes anything.

type ImportResult added in v0.10.0

type ImportResult struct {
	Mod            *domain.Mod
	FilesExtracted int
	LinkedSource   string // "nexusmods", "local", etc.
	AutoDetected   bool   // true if source/ID was parsed from filename
}

ImportResult contains the outcome of importing a local mod

type Importer added in v0.10.0

type Importer struct {
	// contains filtered or unexported fields
}

Importer handles importing mods from local archive files

func NewImporter added in v0.10.0

func NewImporter(cache *cache.Cache) *Importer

NewImporter creates a new Importer that stages extraction in the OS temp dir. Prefer Service.NewImporter, which stages under the data dir instead.

func (*Importer) FindDuplicateMod added in v1.1.0

func (i *Importer) FindDuplicateMod(modName string, installedMods []domain.InstalledMod) *domain.InstalledMod

findDuplicateMod checks if a mod with similar name already exists (for duplicate prevention)

func (*Importer) Import added in v0.10.0

func (i *Importer) Import(ctx context.Context, archivePath string, game *domain.Game, opts ImportOptions) (result *ImportResult, err error)

Import imports a mod from a local archive file

func (*Importer) ScanModPath added in v1.1.0

func (i *Importer) ScanModPath(ctx context.Context, game *domain.Game, installedMods []domain.InstalledMod, opts ScanOptions) ([]ScanResult, error)

ScanModPath scans the game's mod_path for untracked mods

type InstallOptions added in v1.12.0

type InstallOptions struct {
	// TargetVersion, when non-empty, pins the exact version to install for
	// plan.Mod ONLY (#96 decision 6: batch dependencies always install at
	// latest, untouched by this field). Honored on BOTH paths (#140 item 2
	// closed the STRICT gap - previously it was documented-inert there and
	// the CLI compensated by overriding plan.Files, a "flag lies" trap for
	// any other core caller, e.g. a TUI version picker):
	//
	//   - STRICT (no-deps): resolved by resolveStrictInstallFiles at the
	//     very top of ApplyInstall - before the #143 lock gate, any hook,
	//     or any side effect - overwriting plan.Files with the version's
	//     matches (TargetFileIDs' picks within them, else the primary-or-
	//     first heuristic). A plan.Files selection that already sits
	//     entirely inside TargetVersion is kept VERBATIM (no refetch): that
	//     is how the CLI's interactive/--file sub-selection, applied to
	//     plan.Files before this is called, survives unclobbered.
	//   - BATCH (Dependencies-present): the per-mod selection
	//     (applyInstallBatchMod) never consults plan.Files, so the
	//     primary's selection is resolved from this field up front - see
	//     below (#93's silent---version class, found again in #96 review).
	//
	// Resolved ONCE, up front, before any mod (dependency or primary) is
	// touched - not lazily when the loop reaches the primary's turn. A
	// version that doesn't resolve is fatal to the WHOLE install and
	// returned immediately, with zero dependencies installed: the user
	// explicitly asked for this version, so a quiet per-mod "Failed: 1 (X)"
	// summary line is not loud enough, and installing dependencies for a
	// primary that is about to fail to install at all would leave a
	// confusing half-applied state.
	TargetVersion string

	// TargetFileIDs, when non-empty, pins the exact file selection for
	// plan.Mod ONLY - the core-side counterpart of the CLI's --file flag
	// (#140, same silent-flag family as #93/#96: previously --file was
	// silently ignored whenever the named mod had resolvable dependencies).
	// Each ID must resolve within the primary's candidate pool - the
	// TargetVersion matches when TargetVersion is also set, else the
	// plan.ShowArchived-filtered list - and ANY miss is fatal to the WHOLE
	// install, up front (BATCH path: zero dependencies installed, the #96
	// TargetVersion loudness precedent). Dependencies are never affected:
	// they always auto-select their own primary file at latest. Empty means
	// no pin - the BATCH path auto-selects from the pool, the STRICT path
	// installs plan.Files.
	TargetFileIDs []string

	// SkipVerify mirrors doInstall's --skip-verify: when true, a downloaded
	// file's checksum is neither saved (SaveFileChecksum) nor reported via
	// an InstallChecksumComputed event, matching downloadSelectedFiles' "if
	// !skipVerify && checksum != ..." gate exactly for every mod (primary
	// and dependencies alike - batchInstallMods honors the same flag).
	SkipVerify bool

	// Hook plumbing, mirroring UninstallOptions/DeployOptions. Hooks and/or
	// HookRunner may be nil to skip hook execution entirely (e.g.
	// --no-hooks).
	//
	// Force gates install.before_all (once, always) and, in the STRICT
	// (no-deps) path ONLY, the primary's own install.before_each - matching
	// doInstall's own single-mod code exactly (a failure aborts with an
	// error unless Force is set, in which case it is recorded as a Warning
	// and the install proceeds). In the BATCH (Dependencies-present) path,
	// NO mod's before_each - dependency or primary alike - is EVER
	// Force-gated: it unconditionally skips that one mod and continues,
	// matching batchInstallMods exactly (Fix wave 1 - see
	// task-2-report.md's "Fix wave 1" entry - restored this for the primary
	// too; pre-extraction doInstall delegated the WHOLE list, target
	// included, to batchInstallMods whenever Dependencies was non-empty).
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	Force       bool

	// ConfirmConflicts gates the STRICT (no-deps) path's deploy step
	// (applyInstallPrimary), restoring the pre-extraction CLI's blocking
	// conflict prompt at its ORIGINAL position: AFTER the primary is
	// downloaded and extracted to cache and BEFORE it is deployed - the
	// exact point confirmInstallConflicts occupied in doInstall
	// (cmd/lmm/install.go), since installer.GetConflicts can only inspect a
	// mod's cache once something has actually been downloaded into it (see
	// InstallPlan.Conflicts' doc comment for why a pre-download PlanInstall
	// call can't do this for a mod that has never been cached before - the
	// C1 review finding this field fixes: conflicts had regressed into
	// PlanInstall alone, which silently missed every uncached mod's
	// conflicts and, for an already-cached one, prompted at the wrong
	// position).
	//
	// Called with the freshly-computed, non-empty conflict list ONLY when
	// !Force and ConfirmConflicts is non-nil - Force skips the check
	// entirely without ever calling it (matching doInstall's own "if
	// !installForce" gate), and a nil ConfirmConflicts likewise skips it
	// (proceeds silently), for a caller that doesn't want the STRICT path's
	// blocking behavior at all (the BATCH path - applyInstallBatchMod - has
	// its own separate, always-non-blocking inline warning and never
	// consults this field).
	//
	// Returning false aborts the install with the exact error
	// confirmInstallConflicts' decline produced ("installation cancelled"),
	// leaving the same state a decline left in doInstall: before_all/
	// before_each hooks already ran, the download is already cached (a
	// fresh/upgrade install's cache entry is left in place; a same-version
	// reinstall's staged reinstall-cache-transaction is rolled back via its
	// existing deferred Rollback, restoring the live cache/deployed files
	// exactly as they were), and nothing is deployed or saved to the DB/
	// profile.
	ConfirmConflicts func(conflicts []Conflict) bool
}

InstallOptions configures ApplyInstall.

type InstallPlan added in v1.12.0

type InstallPlan struct {
	SourceID, GameID, Profile string

	Mod domain.Mod // the mod that would be installed, freshly fetched via GetMod

	// Files is the file(s) that WOULD be downloaded: GetModFiles' result
	// after filterAndSortInstallFiles (doInstall's filterAndSortFiles,
	// ported - strips ARCHIVED/OLD_VERSION/DELETED unless showArchived, sorts
	// MAIN>OPTIONAL>UPDATE>MISCELLANEOUS>other), then the same non-interactive
	// default cmd/lmm/install.go's selectInstallFiles falls back to (the
	// primary file, or the sole/first file) absent --file or an interactive
	// choice - reusing selectDeployFiles rather than porting
	// selectInstallFiles verbatim, since selectInstallFiles's --file flag and
	// interactive prompt both consume a plan rather than being part of one
	// (see the task report). Always exactly one file in practice: neither
	// selectDeployFiles nor this non-interactive default ever picks more
	// than one without a stored/explicit multi-file selection.
	Files []domain.DownloadableFile

	// Dependencies is target's resolved, not-yet-installed dependency chain,
	// deepest dependency first (install order) - target itself is excluded
	// (it's Mod, above). Mirrors cmd/lmm/install.go's resolveDependencies
	// exactly, including one quirk worth calling out: every dependency is
	// fetched using the TOP-LEVEL SourceID field above, not each
	// ModReference's own SourceID - a dependency listed for a different
	// source therefore always ends up in MissingDependencies unless that
	// source happens to stamp the same SourceID onto the Mod it returns (see
	// resolveInstallDependencies). Empty (with a nil error) whenever the
	// source lacks the Dependencies capability, returns
	// source.ErrNotSupported, or Mod is a local (domain.SourceLocal) mod -
	// resolveInstallDependencies degrades to "no dependencies" rather than
	// failing the plan either way, but (#52 item 10) only records a
	// DependencyWarnings entry when the failure was something OTHER than
	// source.ErrNotSupported - see DependencyWarnings.
	Dependencies []domain.Mod

	// MissingDependencies records dependency references resolveDependencies
	// found but couldn't resolve (source fetch failure, or a SourceID
	// mismatch - see Dependencies) - the pre-extraction CLI's showInstallPlan
	// printed these as a warning, never a failure. Not part of the task
	// brief's directional API struct; added because the brief's own framing
	// ("output contains everything the CLI's pre-install prompts... need to
	// display") requires it to reproduce that warning - see the task report.
	MissingDependencies []domain.ModReference
	// CycleDetected mirrors resolveDependencies' cycleDetected: a circular
	// reference was found while resolving Dependencies (install order is
	// best-effort). Same rationale as MissingDependencies.
	CycleDetected bool

	// DependencyWarnings records one entry per GetDependencies call that
	// failed with something OTHER than source.ErrNotSupported while
	// resolving Dependencies (#52 item 10) - a real fetch failure (rate
	// limit, network blip, malformed response), as opposed to "this source
	// simply doesn't have the Dependencies capability" (ErrNotSupported),
	// which stays silent exactly as before. Either way resolution degrades
	// to "no dependencies found for that mod" and the plan still succeeds -
	// this field exists purely so a caller can tell the user dependency
	// resolution didn't run cleanly, the same way MissingDependencies/
	// CycleDetected surface their own non-fatal degradations. Each entry is
	// "<sourceID:modID>: <error>", already formatted for direct display
	// (see resolveInstallDependencies).
	DependencyWarnings []string

	// Conflicts lists files installing Mod would overwrite from OTHER
	// installed mods, exactly as installer.GetConflicts reports them - but
	// ONLY when Mod's exact (SourceID, ID, Version) is already cached:
	// GetConflicts inspects the cache's extracted file list, and PlanInstall
	// must never download to populate it (see the function doc comment). A
	// mod that has never been downloaded before therefore always reports
	// empty Conflicts here; this mirrors the pre-extraction CLI's own
	// confirmInstallConflicts, which likewise treats ANY GetConflicts error
	// (a cache-miss included) as "no conflicts, continue" rather than an
	// install-blocking failure - see the task report.
	Conflicts []Conflict

	// Replaces is the currently-installed row for (SourceID, Mod.ID,
	// Profile), if any - non-nil means installing this plan would use
	// Installer.Replace (or its reinstall-cache-transaction variants, an
	// Apply-time concern) rather than Installer.Install. Mirrors doInstall's
	// existingMod exactly: populated regardless of whether the installed
	// version matches Mod.Version, so both a same-version reinstall and a
	// version upgrade set this.
	Replaces *domain.InstalledMod

	// TotalDownloadBytes is the sum of Files' declared sizes, or -1 if any
	// selected file's size is unreported (Size <= 0, matching the
	// DownloadProgress convention used elsewhere in this file: only a
	// positive TotalBytes/Size is treated as "known").
	TotalDownloadBytes int64

	// ShowArchived is the showArchived value PlanInstall was called with -
	// stored on the plan (Phase 5b Task 2) so ApplyInstall can resolve each
	// Dependencies entry's own downloadable files (at apply time - see
	// Dependencies' doc comment) using the identical filter the CLI showed
	// the user at plan time, without a second, possibly-inconsistent
	// parameter on InstallOptions. "The plan is the contract."
	ShowArchived bool
}

InstallPlan is the pure, displayable result of PlanInstall: everything the pre-extraction CLI's pre-install prompts (dependency tree, conflict warnings, "already installed" notice) and the TUI's future install modal need to render before a caller decides whether to proceed (Phase 5b Task 2 adds ApplyInstall to actually execute one of these). Computed with zero side effects - see PlanInstall's doc comment.

type InstallResult added in v1.12.0

type InstallResult struct {
	// Installed holds display names in install order: dependencies first,
	// then the primary. In the STRICT (no-deps) path, a primary failure is
	// FATAL - it returns an error instead of appending here. In the BATCH
	// (Dependencies-present) path, the primary follows the exact same
	// skip-and-continue semantics as every dependency (Fix wave 1 - see
	// task-2-report.md's "Fix wave 1" entry) - a primary failure there
	// populates Failed/Skipped below instead of returning an error.
	Installed []string
	// Skipped holds "<name>: <reason>" entries for any mod that failed in
	// the BATCH (Dependencies-present) path - dependency OR primary alike
	// (Fix wave 1 restored the primary's participation; see InstallOptions'
	// Force doc comment). Always empty in the STRICT (no-deps) path, since
	// a primary failure there returns an error instead.
	Skipped []string
	// Failed holds JUST the display names (no reason - see Skipped for
	// that) of every BATCH-path mod that failed, dependency or primary
	// alike, in the SAME order Skipped uses - mirrors batchInstallMods' own
	// `failed []string` accumulator, which the pre-extraction CLI's
	// restored terminal "--- Summary ---\nInstalled: %d\nFailed: %d (%s)\n"
	// block joins verbatim (task-2-report.md's Fix wave 1). Always empty
	// in the STRICT (no-deps) path.
	Failed []string

	// FilesDeployed is the number of files extracted for the STRICT path's
	// PRIMARY mod across all of plan.Files - mirrors doInstall's
	// totalFileCount / the pre-extraction CLI's final "Files deployed: %d"
	// line. Always 0 in the BATCH path (batchInstallMods' terminal summary
	// never printed a file count, only Installed/Failed - see Failed).
	FilesDeployed int

	Warnings []string
	Notes    []string
}

InstallResult reports the outcome of ApplyInstall. As with DeployResult/ UninstallResult/SwitchResult, every entry below is always recorded - there is no verbosity concept in core.

  • Warnings holds diagnostics doInstall/batchInstallMods printed unconditionally: install.before_all/before_each (STRICT-path primary only, when forced), a failed SaveFileChecksum (note: unconditional, NOT --verbose-gated - doInstall/batchInstallMods print this one to stderr regardless), and install.after_each/after_all hook failures. Callers should print each entry to stderr, unconditionally, e.g. `fmt.Fprintf(os.Stderr, "Warning: %v\n", w)`.
  • Notes holds diagnostics doInstall/batchInstallMods only printed under --verbose: a failed profile-create, a failed UpsertMod, a failed reinstall-cache-transaction commit, a failed old-cache cleanup after a version upgrade (all STRICT-path), or - BATCH path only - a failed Uninstall/cache-Delete while removing a mod's previous installation
  • each already carrying its historical "Warning: " prefix baked into the text, matching the pre-extraction CLI's exact wording; a caller wanting byte-identical output should print each entry to stdout ONLY under --verbose, e.g. `fmt.Printf(" %s\n", n)`.

Every entry in both slices is ALSO reported via the progress callback at the exact point it is appended (InstallBeforeAllForced/ InstallBeforeEachForced/InstallWarning/InstallNote - see each DeployPhase constant's doc comment), with Detail equal to the slice entry verbatim.

On error, the returned result carries any diagnostics/counts accumulated before the failure; callers should surface them alongside the error.

type InstalledModResult added in v0.12.0

type InstalledModResult struct {
	domain.Mod
}

InstalledModResult is a successfully installed mod (wraps domain.Mod for batch result)

type Installer

type Installer struct {
	// contains filtered or unexported fields
}

Installer handles mod installation and uninstallation

func NewInstaller

func NewInstaller(cache *cache.Cache, linker linker.Linker, database *db.DB) *Installer

NewInstaller creates a new installer The db parameter is optional - if nil, file tracking is disabled

func (*Installer) GetConflicts added in v0.9.0

func (i *Installer) GetConflicts(ctx context.Context, game *domain.Game, mod *domain.Mod, profileName string) ([]Conflict, error)

GetConflicts checks if installing a mod would overwrite files from other mods. Returns conflicts for files owned by OTHER mods (not the mod being installed).

func (*Installer) GetDeployedFiles

func (i *Installer) GetDeployedFiles(game *domain.Game, mod *domain.Mod) ([]string, error)

GetDeployedFiles returns the list of files deployed for a mod

func (*Installer) Install

func (i *Installer) Install(ctx context.Context, game *domain.Game, mod *domain.Mod, profileName string) error

Install deploys a mod to the game directory. If DB tracking is enabled and a SaveDeployedFile fails, only the file that failed to track is rolled back so the filesystem stays consistent with the database (previously deployed+tracked files are left in place).

func (*Installer) InstallBatch added in v0.12.0

func (i *Installer) InstallBatch(ctx context.Context, game *domain.Game, mods []*domain.Mod, versions []string, profileName string, opts BatchOptions) (*BatchResult, error)

InstallBatch installs multiple mods with hook support Hook behavior: - install.before_all: If fails, return error immediately (unless Force) - install.before_each: If fails, skip that mod, continue others - install.after_each: If fails, warn (add to Errors), continue - install.after_all: If fails, warn (add to Errors)

func (*Installer) IsInstalled

func (i *Installer) IsInstalled(game *domain.Game, mod *domain.Mod) (bool, error)

IsInstalled checks if a mod is currently deployed. Returns true only if every cached file is deployed (partial installs report as not installed).

func (*Installer) Replace added in v1.3.3

func (i *Installer) Replace(ctx context.Context, game *domain.Game, oldMod, newMod *domain.Mod, profileName string) error

Replace swaps an existing deployment with a new cached version and restores the old files if the replacement fails.

func (*Installer) ReplaceForUpdate added in v1.27.0

func (i *Installer) ReplaceForUpdate(ctx context.Context, game *domain.Game, oldMod, newMod *domain.Mod, profileName string, oldFileIDs, newFileIDs []string) error

ReplaceForUpdate is Replace carrying the update path's file-ID transition: the mod's installed file IDs BEFORE the update (oldFileIDs) and the full set being installed by it (newFileIDs - ApplyUpdate's downloadedFileIDs, exactly what it records to the DB row and profile ref). They matter only in the degenerate same-version shape - a file-only update whose version string does not change shares ONE version-keyed cache directory between the old and new files, so the plain union replace could never undeploy a departing file's members (#144 item 4). ApplyRollback wires the same transition reversed - current FileIDs -> PreviousFileIDs - so a same-version rollback narrows identically instead of deploying the union (#150). See resolveSharedDirUpdate for the exact ownership rules; on a normal different-version update (distinct cache dirs) this behaves exactly like Replace.

func (*Installer) ReplaceWithCaches added in v1.3.3

func (i *Installer) ReplaceWithCaches(ctx context.Context, game *domain.Game, oldCache, newCache *cache.Cache, oldMod, newMod *domain.Mod, profileName string) error

ReplaceWithCaches swaps an existing deployment using explicit old and new caches.

func (*Installer) ReplaceWithOldCache added in v1.3.3

func (i *Installer) ReplaceWithOldCache(ctx context.Context, game *domain.Game, oldCache *cache.Cache, oldMod, newMod *domain.Mod, profileName string) error

ReplaceWithOldCache swaps an existing deployment using an alternate cache snapshot for the old version.

func (*Installer) Uninstall

func (i *Installer) Uninstall(ctx context.Context, game *domain.Game, mod *domain.Mod, profileName string) error

Uninstall removes a mod from the game directory

func (*Installer) UninstallBatch added in v0.12.0

func (i *Installer) UninstallBatch(ctx context.Context, game *domain.Game, mods []*domain.InstalledMod, profileName string, opts BatchOptions) (*BatchResult, error)

UninstallBatch uninstalls multiple mods with hook support Hook behavior: - uninstall.before_all: If fails, return error immediately (unless Force) - uninstall.before_each: If fails, skip that mod, continue others - uninstall.after_each: If fails, warn (add to Errors), continue - uninstall.after_all: If fails, warn (add to Errors)

type ParsedFilename added in v0.10.0

type ParsedFilename struct {
	ModID    string // NexusMods mod ID
	Version  string // Mod version (normalized)
	BaseName string // Mod name portion before the ID
}

ParsedFilename contains extracted info from a NexusMods-style filename

func ParseNexusModsFilename added in v0.10.0

func ParseNexusModsFilename(filename string) *ParsedFilename

ParseNexusModsFilename attempts to extract mod ID and version from a NexusMods-style filename like "SkyUI-12604-5-2SE.zip". Returns nil if the filename doesn't match the expected pattern.

type ProfileConflict added in v1.14.0

type ProfileConflict struct {
	Path            string
	Owner           ConflictModRef
	AlsoIn          []ConflictModRef
	LoadOrderWinner ConflictModRef
	Stale           bool
}

ProfileConflict describes one game-directory path that more than one enabled mod in the profile provides. Owner is the mod whose copy of the file is currently deployed per the deployed_files DB table; AlsoIn lists every other provider, in profile load order (unlisted providers first, mirroring OrderByProfile). LoadOrderWinner is the provider that comes LAST in that ordering - the one whose copy a fresh deploy would leave on disk, since later-ordered mods deploy later and overwrite earlier ones. Stale flags a conflict whose DB owner disagrees with the load-order winner (the profile was reordered - or historically, deploy order was nondeterministic - since the last deploy), meaning a redeploy would change which file wins.

type ProfileImportOptions added in v1.14.0

type ProfileImportOptions struct {
	// Force mirrors doProfileImport's --force: passed straight through to
	// ProfileManager.ImportWithOptions, allowing the save to overwrite an
	// already-saved profile of the same name instead of failing.
	Force bool
	// NoInstall mirrors --no-install: the install loop never runs at all
	// (ConfirmInstall is never even consulted - see its own doc comment),
	// and every pending mod is counted in ProfileImportResult.Skipped instead.
	NoInstall bool

	// ConfirmInstall, when non-nil and downloads are pending (and NoInstall
	// is unset), is called AFTER the profile is saved - mirroring the CLI's
	// own prompt position (doProfileImport's "\nDownload and install mods?
	// [Y/n]: " sits right after "\n✓ Imported profile: %s\n") - with the
	// full combined [NeedsRedownload..., Missing...] list. Returning false
	// skips the install loop entirely (every pending mod is counted in
	// Skipped, matching a declined prompt's zero-mutations outcome); nil
	// means proceed unconditionally, matching InstallOptions.ConfirmConflicts'
	// own "nil = proceed" convention.
	ConfirmInstall func(toDownload []domain.ModReference) bool
}

ProfileImportOptions configures ApplyImport.

type ProfileImportResult added in v1.14.0

type ProfileImportResult struct {
	ProfileName                string
	Installed, Failed, Skipped int
	Warnings, Notes            []string
}

ProfileImportResult reports the outcome of ApplyImport. As with every other flow's result type, every field is always recorded - there is no verbosity concept in core.

  • Notes holds the install loop's sole --verbose-gated diagnostic (a failed UpsertMod), matching ApplyProfileSwitch's SwitchInstallNote convention; a caller wanting byte-identical pre-extraction output should print each entry to stdout ONLY under --verbose, e.g. `fmt.Printf(" %s\n", n)` (4-space indent).
  • Warnings holds one "source:mod: reason" entry per failed mod (#131), appended at the same point Failed is bumped - so an outcome-driven caller (the TUI folds these into its completion message's Warnings, mirroring ApplyProfileSwitch's installFailures) keeps the reason and any remediation hint it carries (e.g. #95's stored-files-gone message) after the live progress line is gone.

Every Notes entry is ALSO reported via the progress callback at the exact point it is appended (ImportNote - see its DeployPhase doc comment), with Detail equal to the slice entry verbatim; likewise every Warnings entry has a corresponding ImportModFailed event with Detail equal to the bare reason - a live-printing caller (the CLI) must NOT batch-print Warnings afterward or it would double-report every failure.

On error (a failed save), the returned result carries any diagnostics accumulated before the failure (none, today, since the save is the very first step) - callers should surface it alongside the error.

type ProfileManager

type ProfileManager struct {
	// contains filtered or unexported fields
}

ProfileManager handles profile CRUD operations. Profile switching lives in Service.PlanProfileSwitch/ApplyProfileSwitch (internal/core/flows.go).

func NewProfileManager

func NewProfileManager(configDir string, database *db.DB) *ProfileManager

NewProfileManager creates a new profile manager

func (*ProfileManager) AddMod

func (pm *ProfileManager) AddMod(gameID, profileName string, mod domain.ModReference) error

AddMod adds a mod reference to a profile

func (*ProfileManager) ClearModLock added in v1.26.0

func (pm *ProfileManager) ClearModLock(gameID, profileName, sourceID, modID string) error

ClearModLock clears ONLY the locked marker for sourceID/modID; Version is left exactly as it is - it is the installed-version record, not lock-only data, and unlocking must not disturb it. Mirrors SetModLock's load->mutate-in-place->save shape and not-found error.

func (*ProfileManager) Create

func (pm *ProfileManager) Create(gameID, name string) (*domain.Profile, error)

Create creates a new profile for a game

func (*ProfileManager) Delete

func (pm *ProfileManager) Delete(gameID, name string) error

Delete removes a profile

func (*ProfileManager) Export

func (pm *ProfileManager) Export(gameID, profileName string) ([]byte, error)

Export exports a profile to a portable format

func (*ProfileManager) Get

func (pm *ProfileManager) Get(gameID, name string) (*domain.Profile, error)

Get retrieves a specific profile

func (*ProfileManager) GetDefault

func (pm *ProfileManager) GetDefault(gameID string) (*domain.Profile, error)

GetDefault returns the default profile for a game

func (*ProfileManager) Import

func (pm *ProfileManager) Import(data []byte) (*domain.Profile, error)

Import imports a profile from portable format

func (*ProfileManager) ImportWithOptions

func (pm *ProfileManager) ImportWithOptions(data []byte, force bool) (*domain.Profile, error)

ImportWithOptions imports a profile with optional force overwrite

func (*ProfileManager) List

func (pm *ProfileManager) List(gameID string) ([]*domain.Profile, error)

List returns all profiles for a game

func (*ProfileManager) ParseProfile

func (pm *ProfileManager) ParseProfile(data []byte) (*domain.Profile, error)

ParseProfile parses profile data without saving (for preview)

func (*ProfileManager) RemoveMod

func (pm *ProfileManager) RemoveMod(gameID, profileName, sourceID, modID string) error

RemoveMod removes a mod reference from a profile

func (*ProfileManager) ReorderMods

func (pm *ProfileManager) ReorderMods(gameID, profileName string, mods []domain.ModReference) error

ReorderMods updates the load order of mods in a profile

func (*ProfileManager) SetDefault

func (pm *ProfileManager) SetDefault(gameID, name string) error

SetDefault sets a profile as the default for a game

func (*ProfileManager) SetModLock added in v1.26.0

func (pm *ProfileManager) SetModLock(gameID, profileName, sourceID, modID, version string) error

SetModLock marks the profile ref for sourceID/modID as locked (#97: the mod refuses `lmm update` while this is set - see flows.go's ApplyUpdate gate). A non-empty version also moves the lock's target - ref.Version, the same field the installed-version record lives in (a lock has no separate target field: the record IS the target while locked). version == "" locks at whatever is currently installed, leaving Version untouched. Mirrors UpsertMod's load->mutate-in-place->save shape. Returns an error naming the mod when it is not already in the profile - a lock must target a specific existing install, never create one.

func (*ProfileManager) UpsertMod added in v0.7.5

func (pm *ProfileManager) UpsertMod(gameID, profileName string, mod domain.ModReference) error

UpsertMod adds or updates a mod reference in a profile. If the mod exists, it updates Version and FileIDs while preserving position. If the mod doesn't exist, it appends to the end. This is the preferred method for install/update operations.

A LOCKED existing ref refuses a Version move (#143): the record IS the lock's target (see the #97 design note), so only explicit lock/unlock (SetModLock/ClearModLock) may change a locked ref's Version - never an install/update-style upsert. The refusal wraps ErrModLocked and leaves the profile unwritten. A same-version upsert (a FileIDs refresh / reinstall repair) stays legitimate and preserves the marker as before.

type ProgressFunc

type ProgressFunc func(DownloadProgress)

ProgressFunc is called periodically during download with progress updates

type PurgeOptions added in v1.12.1

type PurgeOptions struct {
	// Uninstall additionally deletes each purged mod's DB record and
	// profile-YAML entry (like uninstalling it), instead of just marking
	// it not deployed - `lmm purge --uninstall`.
	Uninstall bool

	// Hook plumbing, mirroring DeployOptions/InstallOptions: all four
	// uninstall.* hooks fire (purge is an uninstall-family operation).
	// Force continues past a failing uninstall.before_all hook (recorded
	// as a Warning) instead of aborting the purge.
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	Force       bool
}

PurgeOptions configures PurgeProfile.

type PurgeResult added in v1.12.1

type PurgeResult struct {
	Purged   int
	Skipped  []string
	Warnings []string
	Notes    []string
}

PurgeResult reports the outcome of PurgeProfile. Warnings and Notes follow DeployResult's display contract (Warnings: unconditional stderr; Notes: --verbose-gated stdout, historical text baked in). Skipped holds one "<name>: <reason>" entry per mod that was NOT fully purged (a before_each-skipped mod, or an --uninstall record-delete failure); len(Skipped) is doPurge's historical `failed` counter, so the CLI's "Purged: N, Failed: M" summary comes from Purged and len(Skipped).

type ResolvedHooks added in v0.12.0

type ResolvedHooks struct {
	Install   domain.HookConfig
	Uninstall domain.HookConfig
}

ResolvedHooks contains the final merged hooks for an operation

func ResolveHooks added in v0.12.0

func ResolveHooks(game *domain.Game, profile *domain.Profile) *ResolvedHooks

ResolveHooks merges game-level hooks with profile-level overrides

func (*ResolvedHooks) GetInstallAfterAll added in v1.3.1

func (h *ResolvedHooks) GetInstallAfterAll() string

GetInstallAfterAll returns the install.after_all command, or "" if not set.

func (*ResolvedHooks) GetInstallAfterEach added in v1.3.1

func (h *ResolvedHooks) GetInstallAfterEach() string

GetInstallAfterEach returns the install.after_each command, or "" if not set.

func (*ResolvedHooks) GetInstallBeforeAll added in v1.3.1

func (h *ResolvedHooks) GetInstallBeforeAll() string

GetInstallBeforeAll returns the install.before_all command, or "" if not set. Nil-safe: returns "" when the receiver is nil.

func (*ResolvedHooks) GetInstallBeforeEach added in v1.3.1

func (h *ResolvedHooks) GetInstallBeforeEach() string

GetInstallBeforeEach returns the install.before_each command, or "" if not set.

func (*ResolvedHooks) GetUninstallAfterAll added in v1.3.1

func (h *ResolvedHooks) GetUninstallAfterAll() string

GetUninstallAfterAll returns the uninstall.after_all command, or "" if not set.

func (*ResolvedHooks) GetUninstallAfterEach added in v1.3.1

func (h *ResolvedHooks) GetUninstallAfterEach() string

GetUninstallAfterEach returns the uninstall.after_each command, or "" if not set.

func (*ResolvedHooks) GetUninstallBeforeAll added in v1.3.1

func (h *ResolvedHooks) GetUninstallBeforeAll() string

GetUninstallBeforeAll returns the uninstall.before_all command, or "" if not set.

func (*ResolvedHooks) GetUninstallBeforeEach added in v1.3.1

func (h *ResolvedHooks) GetUninstallBeforeEach() string

GetUninstallBeforeEach returns the uninstall.before_each command, or "" if not set.

type RollbackOptions added in v1.14.0

type RollbackOptions struct {
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	Force       bool
}

RollbackOptions configures ApplyRollback, mirroring UpdateOptions' (ApplyUpdate's own, flows.go:2971) hook plumbing exactly: Hooks/HookRunner/ HookContext are supplied by the caller - the CLI resolves them via getHookRunner/getResolvedHooks/makeHookContext, respecting --no-hooks and the configured hook timeout, concerns core deliberately does not reimplement (see UpdateOptions' own doc comment). Hooks and/or HookRunner may be nil to skip hook execution entirely.

Force gates ONLY the rollback's two before_each hooks - uninstall.before_each (the version being rolled back FROM) and install.before_each (the version being rolled back TO) - matching doUpdateRollback's own --force check exactly. As with UpdateOptions, there is no before_all/after_all pair: doUpdateRollback never ran one.

type RollbackResult added in v1.14.0

type RollbackResult struct {
	ModName, FromVersion, ToVersion string
	Warnings, Notes                 []string
}

RollbackResult reports the outcome of ApplyRollback.

  • ModName, FromVersion, ToVersion identify the rollback - split into separate fields (unlike UpdateApplyResult.Applied's single formatted string) because the CLI needs FromVersion/ToVersion independently for its own "Rolling back %s %s → %s..." header, printed BEFORE ApplyRollback is even called (the CLI keeps its own GetInstalledMod call for that header - see ApplyRollback's doc comment). All three are populated as soon as ApplyRollback's guard checks pass - before any hook runs - so a caller can rely on them for its footer even though they are not gated on the whole rollback having succeeded the way UpdateApplyResult.Applied is (ApplyRollback has no equivalent "succeeded end to end" list; callers infer success from a nil error).
  • Warnings holds diagnostics doUpdateRollback printed unconditionally: uninstall.before_each/install.before_each (when forced), and uninstall.after_each/install.after_each hook failures (always non-fatal) - same display contract as UpdateApplyResult.Warnings: callers should print each entry to stderr, unconditionally, e.g. `fmt.Fprintf(os.Stderr, "Warning: %v\n", w)`.
  • Notes holds the sole diagnostic doUpdateRollback only printed under --verbose: a failed SetModLinkMethod, with the historical "Warning: " prefix baked into the text already, matching UpdateApplyResult.Notes' own convention exactly (doUpdateRollback's verbose print was textually identical to applyUpdate's own); a caller wanting byte-identical output should print it to stdout ONLY under --verbose, e.g. `fmt.Printf(" %s\n", n)`.

Every entry in both slices is ALSO reported via the progress callback at the exact point it is appended (UpdateBeforeEachForced/UpdateWarning/ UpdateNote - reused verbatim from ApplyUpdate, see each DeployPhase constant's doc comment), Detail equal to the slice entry verbatim.

On error, the returned result carries any diagnostics/identity fields accumulated before the failure; callers should surface them alongside the error.

type ScanOptions added in v1.1.0

type ScanOptions struct {
	ProfileName string
	DryRun      bool // If true, don't actually import, just report what would be done
}

ScanOptions configures the scan operation

type ScanResult added in v1.1.0

type ScanResult struct {
	FilePath       string      // Original path in mod_path
	FileName       string      // Base filename
	Mod            *domain.Mod // Detected/created mod info
	MatchedSource  string      // a configured source's ID (any registered source, not just curseforge/nexusmods), or "local"
	AlreadyTracked bool        // True if already in lmm database
	Error          error       // Any error during processing

	// ResolvedFile is the matched source's own file this scanned archive
	// corresponds to (#139), when the import flow could resolve one (exact
	// FileName match via ResolveImportedFile); nil otherwise. Set by the
	// import flow after source matching, not by ScanModPath itself.
	ResolvedFile *domain.DownloadableFile
}

ScanResult contains the outcome of scanning a single mod file

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the main orchestrator for mod management operations

func NewService

func NewService(cfg ServiceConfig) (*Service, error)

NewService creates a new core service instance

func (*Service) AddGame

func (s *Service) AddGame(game *domain.Game) error

AddGame adds a new game configuration

func (*Service) ApplyImport added in v1.14.0

func (s *Service) ApplyImport(ctx context.Context, game *domain.Game, plan *ImportPlan, opts ProfileImportOptions, progress func(DeployProgress)) (*ProfileImportResult, error)

ApplyImport executes a plan produced by PlanImport: saves the profile (ProfileManager.ImportWithOptions), then - unless there is nothing to download, NoInstall is set, or ConfirmInstall declines - downloads and installs every NeedsRedownload/Missing mod, in that order, matching doProfileImport exactly (:481-633). Since #138 the install loop also carries ApplyProfileSwitch's convergence machinery: a fully-cached target version (by per-file completion marker) deploys from cache without redownloading, and a version-drift entry with a live prior deployment is Replaced rather than installed over. progress may be nil.

plan is executed EXACTLY as given - like PlanProfileSwitch/ApplyProfileSwitch, this method never re-plans or re-validates it against current state (see that pair's own doc comments for why a speculative plan is cheap enough to simply discard and recompute instead, for a caller that wants to guard against drift).

func (*Service) ApplyInstall added in v1.12.0

func (s *Service) ApplyInstall(ctx context.Context, game *domain.Game, plan *InstallPlan, opts InstallOptions, progress func(DeployProgress)) (*InstallResult, error)

ApplyInstall executes a plan produced by PlanInstall, gated on len(plan.Dependencies) - see the DeployPhase Install* constants' doc comments (starting at InstallBeforeAllForced) for the full restored- fidelity design this reproduces, and task-2-report.md's "Fix wave 1 (dep-path fidelity)" entry for the review trace that drove it:

  • Empty: the STRICT (no-deps) path - only plan.Mod installs, via applyInstallPrimary's doInstall-derived single-mod mechanics (Force-gated hooks, Install-or-Replace, SaveFileChecksum; INTERACTIVE selection is the CALLER's job, applied to plan.Files before this is ever called, while opts.TargetVersion/TargetFileIDs pins are folded into plan.Files HERE, up front, when the caller's selection doesn't already satisfy them (#140 - see resolveStrictInstallFiles) - but the blocking conflict prompt is NOT: it fires INSIDE applyInstallPrimary itself, post-download/pre-deploy, via opts.ConfirmConflicts - see that field's doc comment for why a caller-side, plan.Conflicts-driven prompt can never detect an uncached mod's conflicts, the C1 review finding this restores fidelity for).
  • Non-empty: the BATCH path - plan.Dependencies (in plan order) THEN plan.Mod all install via applyInstallBatchMod, IDENTICALLY, matching batchInstallMods' own "every mod in the list is treated the same" design byte-for-byte - the primary is NOT special-cased here at all (no Replace, no interactive selection, no blocking conflict prompt - see applyInstallBatchMod's own doc comment; its ONE divergence is the up-front opts.TargetVersion/TargetFileIDs pre-resolution, #96/#140, which pins the primary's file selection only).

install.before_all runs once, before any mod is touched, in EITHER path (matching both doInstall's own single-mod code and batchInstallMods, which each had their own, functionally-identical, Force-gated install.before_all call). install.after_all runs once, at the very end: in the STRICT path, only if the primary's own install fully succeeded (an early return skips it entirely, matching doInstall's single-mod code); in the BATCH path, unconditionally once the loop finishes, since no per-mod failure there is ever fatal (matching batchInstallMods, which always reaches its own install.after_all call regardless of how many mods in its list failed). progress may be nil.

On error, the returned result carries any diagnostics/Installed entries accumulated before the failure - callers should surface them alongside the error (see InstallResult's doc comment).

func (*Service) ApplyModUpdate added in v1.3.3

func (s *Service) ApplyModUpdate(sourceID, modID, gameID, profileName, newVersion string, fileIDs []string) error

ApplyModUpdate updates version and file IDs atomically, preserving rollback state.

func (*Service) ApplyProfileSwitch added in v1.11.0

func (s *Service) ApplyProfileSwitch(ctx context.Context, game *domain.Game, plan *SwitchPlan, progress func(DeployProgress)) (*SwitchResult, error)

ApplyProfileSwitch executes a plan produced by PlanProfileSwitch: disables every ToDisable mod, then enables every ToEnable mod, then downloads and installs every ToInstall mod, and finally calls ProfileManager.SetDefault to make plan.To the active profile - in that order, matching doProfileSwitch exactly. progress may be nil.

doProfileSwitch runs no install/uninstall hooks at all (unlike DeployProfile/UninstallMod), so ApplyProfileSwitch doesn't either - there is deliberately no hook plumbing in its signature or DeployOptions-style options struct, since profile switch takes no CLI flags beyond the target profile name.

plan is executed EXACTLY as given - this method never re-plans or re-validates it against current state. A caller that computed plan some time ago (e.g. to show a user a preview) and only calls this later, after showing that preview, accepts whatever has changed in the interim as already baked into plan; PlanProfileSwitch's own doc comment documents why speculative plans are cheap enough to discard and recompute instead. The TUI's coreProvider.ApplyProfileSwitch (Task 6 item e) is exactly this caller: it re-plans immediately before calling this method, which is a SEPARATE PlanProfileSwitch call from whichever one built the confirmation modal the user actually saw - see that method's own doc comment for the resulting preview/apply drift this can introduce.

func (*Service) ApplyRollback added in v1.14.0

func (s *Service) ApplyRollback(ctx context.Context, game *domain.Game, profileName, sourceID, modID string, opts RollbackOptions, progress func(DeployProgress)) (*RollbackResult, error)

ApplyRollback rolls the installed mod identified by sourceID/modID back to its PreviousVersion, following cmd/lmm/update.go's pre-extraction doUpdateRollback ordering exactly: GetInstalledMod -> guard checks -> hooks -> installer.ReplaceForUpdate(current -> previous) - the extracted CLI's plain Replace step, now carrying the reversed file-ID transition (current FileIDs -> PreviousFileIDs) that narrows a same-version rollback to the restored file's own members (#150) -> RollbackModVersion (DB swap, with a compensating reverse-replace on failure) -> SetModLinkMethod -> reload -> ProfileManager.UpsertMod (compensating BOTH the DB swap and the Replace on failure). This is a behavior-preserving extraction - see the task report for the full mapping. Unlike ApplyUpdate, there is no download step at all - the previous version's files already live in the cache (ApplyUpdate itself guarantees this: it never deletes a mod's OLD cache entry - see ApplyUpdate's own doc comment) - so the FIRST thing this function's caller-visible behavior depends on is that cache entry still existing.

Guards, checked before anything else, in order: mod.PreviousVersion must be non-empty ("no previous version available for rollback" - a mod that has never been updated, or has already been rolled back once, has no second previous version to roll back to), and the previous version must still exist in the game's cache ("previous version %s not found in cache" - defends against a cache entry pruned or manually deleted between the update and the rollback). Both mirror doUpdateRollback's own two precondition checks verbatim, including their exact error text (the CLI's own "mod not found: %s" wrapping of a failed GetInstalledMod is preserved here too, for the same reason).

Hook failure semantics mirror doUpdateRollback's own two, independently Force-gated before_each hooks (uninstall.before_each for the CURRENT version, install.before_each for the PREVIOUS version being redeployed: fatal unless Force is set, in which case a Warning is recorded and the rollback proceeds) and its two always-non-fatal after_each hooks (uninstall.after_each, install.after_each - both recorded as Warnings regardless of Force, run in that order immediately after Replace, well before the DB/profile writes below - see UpdateWarning's doc comment).

A failure to write RollbackModVersion triggers a best-effort compensating reverse Installer.ReplaceForUpdate (redeploying the CURRENT version with the file-ID transition swapped back, undoing the replace this function just performed) before returning the error; a failure to write ProfileManager.UpsertMod afterward compensates BOTH - another RollbackModVersion (undoing the DB swap) AND another reverse replace - matching doUpdateRollback's own two, textually-near-identical compensation blocks exactly. A failure reloading the rolled-back mod (the GetInstalledMod call between those two steps) is, however, NOT compensated - matching doUpdateRollback's own verbatim behavior, a pre-existing gap this extraction preserves rather than fixes (see the task report). A failure to write SetModLinkMethod is NOT rolled back either, matching doUpdateRollback exactly (it only ever produced a --verbose-gated Note).

progress may be nil. On error, the returned result carries any diagnostics/identity fields accumulated before the failure - callers should surface them alongside the error (see RollbackResult's doc comment).

func (*Service) ApplyUpdate added in v1.12.0

func (s *Service) ApplyUpdate(ctx context.Context, game *domain.Game, profileName string, upd domain.Update, opts UpdateOptions, progress func(DeployProgress)) (*UpdateApplyResult, error)

ApplyUpdate applies upd to the installed mod it references (upd.InstalledMod), following cmd/lmm/update.go's pre-extraction applyUpdate ordering exactly: GetMod (the new version) -> GetModFiles -> resolve FileIDReplacements -> download -> hooks -> installer.ReplaceForUpdate (Replace at extraction time; it has since gained the file-ID transition for #144 item 4's same-version shape) -> ApplyModUpdate -> SetModLinkMethod -> UpsertMod. This is a behavior-preserving extraction - see the task report for the full mapping.

FileIDReplacements resolution mirrors applyUpdate exactly: each of the installed mod's own FileIDs is looked up in upd.FileIDReplacements; a hit substitutes the new (superseding) file ID, a miss retains the ORIGINAL id verbatim (never silently dropped). Those IDs are then handed to selectUpdateDeployFiles, for which they are only a tie-break WITHIN upd.NewVersion's own files - see that function's doc comment (#143) for why the update path, alone among the flows, cannot let stored IDs outrank the target version, and for when its selectDeployFiles primary-file fallback (#95) still applies.

A download failure returns immediately - before any hook runs, before Replace, before any DB/profile write - so the old version is left deployed and every row untouched, matching applyUpdate's own bare early return. Installer.Replace never touches the cache (only the game directory and deployed-file tracking - see installer.go), so the OLD version's cache entry always survives an update; ApplyModUpdate records PreviousVersion/PreviousFileIDs before overwriting version/FileIDs - both preconditions `lmm update rollback` (doUpdateRollback, NOT extracted by this task - see the task report) depends on.

Hook failure semantics mirror applyUpdate's own two, independently Force-gated before_each hooks (uninstall.before_each for the OLD mod, install.before_each for the NEW mod: fatal unless Force is set, in which case a Warning is recorded and the update proceeds) and its two always- non-fatal after_each hooks (uninstall.after_each, install.after_each - both recorded as Warnings regardless of Force, printed immediately after Replace, well before the DB/profile writes below - see UpdateWarning's doc comment).

A failure to write ApplyModUpdate or UpsertMod triggers the same best-effort compensating actions applyUpdate itself performed (a reverse Installer.ReplaceForUpdate - the file-ID transition reversed - to restore the old deployment, plus - for UpsertMod - a RollbackModVersion to undo the DB version swap first); a failure to write SetModLinkMethod is NOT rolled back, matching applyUpdate exactly (it only ever produced a --verbose-gated Note).

progress may be nil. On error, the returned result carries any diagnostics accumulated before the failure - callers should surface them alongside the error (see UpdateApplyResult's doc comment).

func (*Service) AvailableModVersions added in v1.26.0

func (s *Service) AvailableModVersions(ctx context.Context, sourceID string, mod *domain.Mod) ([]string, error)

AvailableModVersions lists the distinct per-file versions mod's source reports, in first-seen order - the TUI version picker's data (#97). Wraps source.ErrNotSupported (same format as ResolveVersionFiles) when the file list carries no version info at all.

func (*Service) Cache

func (s *Service) Cache() *cache.Cache

Cache returns the default cache manager

func (*Service) Close

func (s *Service) Close() error

Close releases resources held by the service

func (*Service) ConfigDir

func (s *Service) ConfigDir() string

ConfigDir returns the configuration directory

func (*Service) DeleteInstalledMod added in v1.3.7

func (s *Service) DeleteInstalledMod(sourceID, modID, gameID, profileName string) error

DeleteInstalledMod removes the installed-mod record from the active profile.

func (*Service) DeleteSourceToken

func (s *Service) DeleteSourceToken(sourceID string) error

DeleteSourceToken removes an API token for a source

func (*Service) DeployProfile added in v1.11.0

func (s *Service) DeployProfile(ctx context.Context, game *domain.Game, profileName string, opts DeployOptions, progress func(DeployProgress)) (*DeployResult, error)

DeployProfile redeploys the mods of a profile in profile order: an optional --purge pass first (undeploying every installed mod), then for each mod to deploy - re-downloading from source if its cache entry is missing - an undeploy-then-install cycle recording the effective link method and deployed state, and finally applying any profile overrides. This is a behavior-preserving extraction of the pre-extraction CLI's doDeploy (cmd/lmm/deploy.go) and purgeDeployedMods (cmd/lmm/purge.go's --purge-before-deploy variant; the standalone `lmm purge` command was later extracted too, as PurgeProfile, and since #61 both purges share purgeMods); see the task report for the exact mapping.

progress may be nil. When non-nil, it is called synchronously from this function for every notable event - see DeployPhase's constants for what each one means and what Detail/Percent carry.

func (*Service) DisableMod added in v1.11.0

func (s *Service) DisableMod(ctx context.Context, game *domain.Game, profileName, sourceID, modID string) (*DisableResult, error)

DisableMod undeploys the mod's files from the game directory — the cache entry is kept so the mod can be re-enabled later without downloading again — and marks it disabled in the database. Returns a result with Changed false — not an error — if the mod was already disabled.

Undeploy failures are treated as non-fatal: the game files may already have been removed manually, and refusing to record the user's intent to disable the mod would leave it stuck. This mirrors the pre-extraction CLI, which warned (under --verbose) but always continued to flip the DB state — DisableResult.Notes (Task 6 item a) restores that diagnostic for callers that want it, rather than discarding it as the (bool, error) signature this replaces was forced to.

func (*Service) DownloadMod

func (s *Service) DownloadMod(ctx context.Context, sourceID string, game *domain.Game, mod *domain.Mod, file *domain.DownloadableFile, progressFn ProgressFunc) (result *DownloadModResult, err error)

DownloadMod downloads a mod file, extracts it, and stores it in the cache Returns the download result including files extracted and checksum. Multiple files from the same mod can be downloaded to the same cache location.

func (*Service) DownloadModToCache added in v1.3.3

func (s *Service) DownloadModToCache(ctx context.Context, gameCache *cache.Cache, sourceID string, game *domain.Game, mod *domain.Mod, file *domain.DownloadableFile, progressFn ProgressFunc) (result *DownloadModResult, err error)

DownloadModToCache downloads a mod file, extracts it, and stores it in the provided cache.

func (*Service) EnableMod added in v1.11.0

func (s *Service) EnableMod(ctx context.Context, game *domain.Game, profileName, sourceID, modID string) (*EnableResult, error)

EnableMod deploys an installed-but-disabled mod's files from the cache to the game directory and marks it enabled in the database. Returns a result with Changed false — not an error — if the mod was already enabled.

func (*Service) GetDefaultLinkMethod

func (s *Service) GetDefaultLinkMethod() domain.LinkMethod

GetDefaultLinkMethod returns the default link method from config

func (*Service) GetDependencies added in v0.11.0

func (s *Service) GetDependencies(ctx context.Context, sourceID string, mod *domain.Mod) ([]domain.ModReference, error)

GetDependencies returns dependencies for a mod from the specified source

func (*Service) GetDeployedFilesForMod added in v1.3.7

func (s *Service) GetDeployedFilesForMod(gameID, profileName, sourceID, modID string) ([]string, error)

GetDeployedFilesForMod returns the relative paths the given mod has deployed in the named profile.

func (*Service) GetDownloadURL

func (s *Service) GetDownloadURL(ctx context.Context, sourceID string, mod *domain.Mod, fileID string) (string, error)

GetDownloadURL gets the download URL for a specific mod file

func (*Service) GetEffectiveLinkMethod added in v1.27.0

func (s *Service) GetEffectiveLinkMethod(game *domain.Game, profileName string) domain.LinkMethod

GetEffectiveLinkMethod resolves the link method for operations that deploy into (or undeploy from) profileName: profile-explicit > game-explicit > global default (#81). A missing or unreadable profile degrades to the game-level resolution rather than erroring - callers resolving a method are deploying, not validating, and the profile's absence is diagnosed elsewhere. The CLI --method override sits above all of these and is applied by callers (see DeployOptions.LinkMethod).

func (*Service) GetFileOwner added in v1.3.7

func (s *Service) GetFileOwner(gameID, profileName, relativePath string) (sourceID, modID string, found bool, err error)

GetFileOwner reports which mod currently owns a deployed file. The bool is false when no record exists; err is non-nil only on storage errors.

func (*Service) GetFilesWithChecksums added in v1.3.7

func (s *Service) GetFilesWithChecksums(gameID, profileName string) ([]DeployedFile, error)

GetFilesWithChecksums returns every tracked file in the profile with its recorded checksum (empty when none has been computed yet).

func (*Service) GetGame

func (s *Service) GetGame(gameID string) (*domain.Game, error)

GetGame retrieves a game by ID

func (*Service) GetGameCache

func (s *Service) GetGameCache(game *domain.Game) *cache.Cache

GetGameCache returns a cache manager for the specified game. Uses the game's cache_path if configured (game-scoped: paths omit gameID), otherwise the global cache.

func (*Service) GetGameCachePath

func (s *Service) GetGameCachePath(game *domain.Game) string

GetGameCachePath returns the effective cache path for a game. Uses the game's cache_path if configured, otherwise falls back to global cache.

func (*Service) GetGameLinkMethod

func (s *Service) GetGameLinkMethod(game *domain.Game) domain.LinkMethod

GetGameLinkMethod returns the effective link method for a game. Uses the game's explicit setting if configured, otherwise falls back to global default.

func (*Service) GetInstalledMod

func (s *Service) GetInstalledMod(sourceID, modID, gameID, profileName string) (*domain.InstalledMod, error)

GetInstalledMod retrieves a single installed mod

func (*Service) GetInstalledMods

func (s *Service) GetInstalledMods(gameID, profileName string) ([]domain.InstalledMod, error)

GetInstalledMods returns all installed mods for a game/profile (DB order: installed_at).

func (*Service) GetInstalledModsInProfileOrder added in v1.1.0

func (s *Service) GetInstalledModsInProfileOrder(gameID, profileName string) ([]domain.InstalledMod, error)

GetInstalledModsInProfileOrder returns installed mods in profile load order (first = lowest priority). Mods not present in the profile are omitted. Use this for deploy/switch so deployment order matches load order.

func (*Service) GetInstaller added in v0.7.8

func (s *Service) GetInstaller(game *domain.Game) *Installer

GetInstaller returns an Installer configured for the given game

func (*Service) GetInstallerForProfile added in v1.27.0

func (s *Service) GetInstallerForProfile(game *domain.Game, profileName string) *Installer

GetInstallerForProfile returns an Installer whose linker honors profileName's effective link method (GetEffectiveLinkMethod) - the profile-aware companion to GetInstaller.

func (*Service) GetLastDeployTime added in v1.18.0

func (s *Service) GetLastDeployTime(gameID, profileName string) (*time.Time, error)

GetLastDeployTime returns the timestamp of the most recent deploy for the given game/profile (#106a's dashboard "Last deploy" row), or nil if it has never been deployed - see db.DB.GetLastDeployTime's own doc comment for why nil is not an error.

func (*Service) GetLinker

func (s *Service) GetLinker(method domain.LinkMethod) linker.Linker

GetLinker returns a linker for the given method

func (*Service) GetMod

func (s *Service) GetMod(ctx context.Context, sourceID, gameID, modID string) (*domain.Mod, error)

GetMod retrieves a specific mod from a source

func (*Service) GetModFiles

func (s *Service) GetModFiles(ctx context.Context, sourceID string, mod *domain.Mod) ([]domain.DownloadableFile, error)

GetModFiles retrieves available download files for a mod

func (*Service) GetProfileConflicts added in v1.14.0

func (s *Service) GetProfileConflicts(ctx context.Context, game *domain.Game, profileName string) ([]ProfileConflict, error)

GetProfileConflicts is a pure read-only query returning every file path in the named profile that more than one mod provides, sorted by Path. Only ENABLED installed mods are considered: they are the set that participates in deployment, so a disabled mod's files can never contend for a game path. Each mod's provided files come from its cache manifest (the same source install-time conflict checking uses) - NOT from the deployed_files table, whose single-owner-per-path schema can only ever name one provider per file; a mod whose cache entry is missing (e.g. manually deleted) simply contributes no files rather than failing the query, while any other cache read failure (permissions, corruption) aborts with an error rather than silently under-reporting conflicts. Ownership per path still comes from deployed_files (GetFileOwner); a path with no recorded owner is skipped, matching the pre-extraction CLI's behavior of only reporting conflicts on tracked deployments.

The profile's load order is read via the ProfileManager; a profile that fails to load is treated as empty (nil), so every provider counts as unlisted and ordering stays deterministic (sorted by key) rather than the query aborting - mirroring OrderByProfile's nil handling. The query reads the DB (GetInstalledMods, GetFileOwner) and walks each enabled mod's cache directory (ListFiles); ctx is checked between per-mod cache walks so a caller cancelling mid-query (e.g. the TUI's quit-drain) gets ctx.Err() promptly instead of paying for the remaining walks.

func (*Service) GetSource

func (s *Service) GetSource(id string) (source.ModSource, error)

GetSource retrieves a source by ID

func (*Service) GetSourceToken

func (s *Service) GetSourceToken(sourceID string) (*db.StoredToken, error)

GetSourceToken retrieves an API token for a source

func (*Service) IsSourceAuthenticated

func (s *Service) IsSourceAuthenticated(sourceID string) bool

IsSourceAuthenticated checks if a source has a stored API token

func (*Service) ListGames

func (s *Service) ListGames() []*domain.Game

ListGames returns all configured games

func (*Service) ListSourceTokens added in v1.8.0

func (s *Service) ListSourceTokens() ([]db.StoredToken, error)

ListSourceTokens returns every stored API token, including ones whose source is no longer registered (e.g. the custom-source definition file was removed) — used by `lmm auth status` to surface orphaned credentials.

func (*Service) ListSources

func (s *Service) ListSources() []source.ModSource

ListSources returns all registered sources

func (*Service) MarkImportedFileComplete added in v1.27.0

func (s *Service) MarkImportedFileComplete(game *domain.Game, mod *domain.Mod, fileID string) error

MarkImportedFileComplete stamps fileID's completion marker - with the entry's member manifest - onto mod's import-written cache entry (#139), so the file-granular cache-first guards (Cache.HasFileIDs) recognize the entry instead of forcing one redundant redownload, and provenance-based undeploy narrowing can attribute its members. Import writes the cache directly (no staging commit), so the marker is stamped after the fact; the members are whatever the entry actually holds.

func (*Service) NewImporter added in v1.14.1

func (s *Service) NewImporter(game *domain.Game) *Importer

NewImporter creates an Importer for game, staging archive extraction under the service's data dir rather than $TMPDIR.

func (*Service) NewInstallerWithLinker added in v1.3.7

func (s *Service) NewInstallerWithLinker(game *domain.Game, lnk linker.Linker) *Installer

NewInstallerWithLinker returns an Installer for the given game using a caller-supplied linker — used when the CLI overrides the game's default link method (e.g. `lmm deploy --method`).

func (*Service) NewProfileManager added in v1.3.7

func (s *Service) NewProfileManager() *ProfileManager

NewProfileManager returns a ProfileManager wired to this service's storage, so callers do not need direct access to the database or registry.

func (*Service) NewUpdater added in v1.3.7

func (s *Service) NewUpdater() *Updater

NewUpdater returns an Updater wired to this service's source registry.

func (*Service) PlanImport added in v1.14.0

func (s *Service) PlanImport(ctx context.Context, game *domain.Game, data []byte) (*ImportPlan, error)

PlanImport parses data (an exported profile) and categorizes its mods against game's current installed/cache state, without saving anything or touching the network - mirrors doProfileImport's preview step (:411-459) exactly. ctx is accepted for API consistency with the rest of Service's methods (see PlanProfileSwitch's own doc comment for why a speculative, side-effect-free plan doesn't need it today).

func (*Service) PlanInstall added in v1.12.0

func (s *Service) PlanInstall(ctx context.Context, game *domain.Game, profileName, sourceID, modID string, showArchived bool) (*InstallPlan, error)

PlanInstall computes what installing (sourceID, modID) into profileName would do - the pure, read-only half of the pre-extraction CLI's doInstall (cmd/lmm/install.go), extracted with zero mutations so a caller (the CLI, or the TUI's future install modal) can render it and decide whether to proceed before Phase 5b Task 2's ApplyInstall executes it. See InstallPlan's doc comment for what each field means, and the task report for the exact mapping back to doInstall.

Deliberately NOT reproduced here (both consume a plan rather than being part of one, matching PlanProfileSwitch's precedent):

  • doInstall's interactive file picking / --file flag (selectInstallFiles) and its "Install N mod(s)? Y/n" dependency confirm prompt - Files always reflects the same non-interactive default cmd/lmm's own --yes flag would pick; a CLI/TUI caller that resolves a different selection overrides plan.Files before calling ApplyInstall.
  • --no-deps: a caller that wants to skip Dependencies can simply ignore or clear them before calling ApplyInstall.

showArchived mirrors doInstall's --show-archived flag exactly: it is threaded straight into filterAndSortInstallFiles (the faithful port of cmd/lmm/install.go's filterAndSortFiles - same ARCHIVED/OLD_VERSION/DELETED filter set, same MAIN>OPTIONAL>UPDATE>MISCELLANEOUS>other sort), which runs BEFORE the "no downloadable files" check and BEFORE selectDeployFiles - so a mod whose files are all archived reports the CLI's exact error instead of a plan, and the no-IsPrimary fallback picks the CLI's post-sort file, not GetModFiles' raw-order first. This parameter exists so Task 2's CLI refit can pass installShowArchived straight through without re-porting filterAndSortFiles into cmd/lmm a second time - see the task report's Fix wave 1 for why a parameter (rather than a hardcoded false, or a separate options type/overload) is the shape picked here.

Network reads (GetMod, GetDependencies, GetModFiles) are expected; no DB write, filesystem write, cache write, hook execution, or download ever happens here - see TestService_PlanInstall_PerformsZeroMutations.

func (*Service) PlanProfileSwitch added in v1.11.0

func (s *Service) PlanProfileSwitch(ctx context.Context, game *domain.Game, target string) (*SwitchPlan, error)

PlanProfileSwitch computes the diff between game's currently-active default profile and target, without mutating anything (no DB writes, no filesystem changes, no deploys) - callers may call this speculatively (to render a confirmation modal) and discard the result without consequence. See SwitchPlan's doc comment; ctx is accepted for API consistency with the rest of Service's methods and future-proofing, even though today's algorithm performs no I/O that needs it.

func (*Service) PurgeProfile added in v1.12.1

func (s *Service) PurgeProfile(ctx context.Context, game *domain.Game, profileName string, mods []domain.InstalledMod, opts PurgeOptions, progress func(DeployProgress)) (*PurgeResult, error)

PurgeProfile undeploys every mod in mods from game's directory - the `lmm purge` command's flow, a behavior-preserving extraction of cmd/lmm/purge.go's doPurge (#61). The caller fetches mods (via GetInstalledMods) and confirms with the user first, so the set shown in the confirmation prompt is exactly the set purged; an empty mods slice returns immediately - no hooks, no events (the "No mods installed" message stays caller-side). Without opts.Uninstall each mod's record is kept and marked not-deployed; with it, records and profile entries are removed. Undeploy and DB-mark failures are best-effort (Notes); a before_each hook failure or --uninstall record-delete failure skips that mod (Skipped).

progress may be nil. Cancellation is honored between mods (the partial-result convention: the accumulated result comes back alongside ctx.Err()); one cancellation-behavior delta from the pre-extraction doPurge, which never checked ctx mid-loop.

func (*Service) RegisterSource

func (s *Service) RegisterSource(src source.ModSource)

RegisterSource adds a mod source to the registry

func (*Service) ResolveImportedFile added in v1.27.0

func (s *Service) ResolveImportedFile(ctx context.Context, sourceID string, sourceMod *domain.Mod, archiveFilename, version string, allowVersionFallback bool) (*domain.DownloadableFile, error)

ResolveImportedFile resolves the source file an imported archive corresponds to (#139): sourceMod is the already-fetched source mod (the import flow fetched it for metadata enrichment or scan matching), version the version the import recorded locally. Matching follows matchImportedFile's rules; a clean no-match or ambiguity is (nil, nil), an error only reports a failed source listing - callers treat it as non-fatal and keep the import marker-less.

func (*Service) ResolveModVersion added in v1.25.0

func (s *Service) ResolveModVersion(ctx context.Context, sourceID string, mod *domain.Mod, version string) ([]domain.DownloadableFile, error)

ResolveModVersion fetches mod's raw file list from sourceID and resolves version against it via ResolveVersionFiles (#96). The list is deliberately unfiltered - archived files are exactly what a version pin usually names.

func (*Service) RollbackModVersion

func (s *Service) RollbackModVersion(sourceID, modID, gameID, profileName string) error

RollbackModVersion reverts a mod to its previous version

func (*Service) SaveFileChecksum added in v1.3.7

func (s *Service) SaveFileChecksum(sourceID, modID, gameID, profileName, fileID, checksum string) error

SaveFileChecksum records the verified checksum for a downloaded mod file.

func (*Service) SaveInstalledMod added in v1.3.7

func (s *Service) SaveInstalledMod(mod *domain.InstalledMod) error

SaveInstalledMod persists an installed-mod record (insert or update).

func (*Service) SaveSourceToken

func (s *Service) SaveSourceToken(sourceID, apiKey string) error

SaveSourceToken saves an API token for a source

func (*Service) SearchAllSources added in v1.10.0

func (s *Service) SearchAllSources(ctx context.Context, gameID, query, category string, tags []string, page, pageSize int) (AggregateSearchResult, error)

SearchAllSources searches every source configured for a game concurrently and merges the results (design §5). Per-source failures become Warnings — one flaky API must not hide local modlets; only all-sources-failed is an error. Sources without search capability are skipped silently. Pagination is per-source: page N requests page N from each source and merges.

func (*Service) SearchMods

func (s *Service) SearchMods(ctx context.Context, sourceID, gameID, query string, category string, tags []string, page, pageSize int) (source.SearchResult, error)

SearchMods searches for mods in a source

func (*Service) SetModDeployed added in v1.3.7

func (s *Service) SetModDeployed(sourceID, modID, gameID, profileName string, deployed bool) error

SetModDeployed records whether a mod's files are currently deployed.

func (*Service) SetModEnabled added in v1.3.7

func (s *Service) SetModEnabled(sourceID, modID, gameID, profileName string, enabled bool) error

SetModEnabled toggles the enabled flag for an installed mod.

func (*Service) SetModFileIDs added in v0.7.3

func (s *Service) SetModFileIDs(sourceID, modID, gameID, profileName string, fileIDs []string) error

SetModFileIDs updates the file IDs for an installed mod

func (*Service) SetModLinkMethod

func (s *Service) SetModLinkMethod(sourceID, modID, gameID, profileName string, linkMethod domain.LinkMethod) error

SetModLinkMethod sets the deployment method for an installed mod

func (*Service) SetModUpdatePolicy

func (s *Service) SetModUpdatePolicy(sourceID, modID, gameID, profileName string, policy domain.UpdatePolicy) error

SetModUpdatePolicy sets the update policy for an installed mod

func (*Service) SetModVersion added in v1.23.0

func (s *Service) SetModVersion(sourceID, modID, gameID, profileName, version string) error

SetModVersion corrects an installed mod's recorded version without shifting PreviousVersion (unlike a real version update) or re-keying its file-ID rows (unlike SaveInstalledMod's full-row upsert, whose replaceModFileIDsTx would silently wipe stored checksums even when the file IDs themselves haven't changed - see internal/storage/db/mods.go). For repairing a version string known to be WRONG (verify --fix's version-record repair, issue #94), where the file IDs and their checksums are already correct.

func (*Service) SourceCapabilities added in v1.26.0

func (s *Service) SourceCapabilities(sourceID string) (source.Capabilities, error)

SourceCapabilities reports sourceID's declared capabilities (#97: static lock gating). Mirrors SearchAllSources' registry access (service.go's source.CapabilitiesOf(src) call).

func (*Service) SourcesForGame added in v1.22.0

func (s *Service) SourcesForGame(gameID string) ([]source.ModSource, error)

SourcesForGame resolves gameID and returns the subset of its configured sources (game.SourceIDs keys) that are currently registered, sorted by ID(). A SourceIDs key with no matching registration is silently skipped - this function has no per-item error channel, only resolved ModSource values come back - matching SearchAllSources's existing tolerance for the same situation. An unknown game is the only error case.

func (*Service) UninstallMod added in v1.11.0

func (s *Service) UninstallMod(ctx context.Context, game *domain.Game, profileName, sourceID, modID string, opts UninstallOptions) (*UninstallResult, error)

UninstallMod removes a mod from the profile: runs uninstall hooks, undeploys files, deletes the cache entry (unless KeepCache), removes the DB row, and removes the mod from the profile YAML.

Hook failure semantics (matching the pre-extraction CLI's doUninstall):

  • uninstall.before_all / uninstall.before_each: a failure aborts the operation with an error, unless Force is set, in which case it is recorded in Warnings and the uninstall proceeds.
  • uninstall.after_each / uninstall.after_all: always non-fatal; a failure is recorded in Warnings after every other step has already committed.

Undeploy failures, cache-delete failures, and a failure to remove the mod from the profile (e.g. the DB and profile have drifted out of sync) are all non-fatal and always recorded in Notes; the operation still completes. See UninstallResult's doc comment for the Warnings/Notes display contract.

func (*Service) UpdateModVersion

func (s *Service) UpdateModVersion(sourceID, modID, gameID, profileName, newVersion string) error

UpdateModVersion updates the version of an installed mod, preserving the previous version for rollback

type ServiceConfig

type ServiceConfig struct {
	ConfigDir string // Directory for configuration files
	DataDir   string // Directory for database and persistent data
	CacheDir  string // Directory for mod file cache
}

ServiceConfig holds configuration for the core service

type SkippedMod added in v0.12.0

type SkippedMod struct {
	Mod    *domain.Mod
	Reason string
}

SkippedMod represents a mod that was skipped during batch operation

type SourceWarning added in v1.10.0

type SourceWarning struct {
	SourceID string
	Err      error
}

SourceWarning reports a per-source failure during an aggregate operation.

type SwitchPlan added in v1.11.0

type SwitchPlan struct {
	GameID, From, To string

	ToEnable  []domain.InstalledMod // installed+disabled (or installed under a different profile) -> enable, deployed under To
	ToDisable []domain.InstalledMod // enabled under From but absent from To -> disable, undeployed under From
	ToInstall []domain.ModReference // in To but not installed anywhere -> download+install (FileIDs preserved from the installed mod's own record when this is really a cache-miss redeploy - see PlanProfileSwitch)

	// PriorVersions carries, for each ToInstall entry that is really a #96
	// version-drift convergence (keyed by domain.ModKey(SourceID, ModID)),
	// the installed row being converged AWAY from - review round 1 finding
	// 1: ToInstall's own element type (domain.ModReference) has no room for
	// this, but ApplyProfileSwitch's install loop needs it to know whether
	// a LIVE older deployment exists that must be replaced (removing files
	// the new version doesn't serve) rather than merely installed over -
	// mirroring ApplyUpdate's Installer.Replace semantics. Absent for every
	// other ToInstall entry (brand-new installs, cache-miss redeploys).
	PriorVersions map[string]domain.InstalledMod

	NoChanges     bool // To's mod set matches From's content-wise; only SetDefault is needed
	AlreadyActive bool // To is already the active default profile; nothing to plan
}

SwitchPlan is the pure, displayable diff between the currently-active default profile and a target profile - computed by PlanProfileSwitch with zero side effects, so a caller (the CLI, or eventually the TUI) can render it (in a print block or a confirmation modal) before deciding whether to call ApplyProfileSwitch. This is a behavior-preserving extraction of cmd/lmm/profile.go's doProfileSwitch's diff computation (through its "Show changes" print block) - see the task report for the exact mapping.

CRITICAL: this mirrors the CLI's OWN diff algorithm. (An older, unused ProfileManager.Switch implementation coexisted with it until #60 retired it - this flow is the only switch implementation now.)

type SwitchResult added in v1.11.0

type SwitchResult struct {
	Disabled, Enabled, Installed int
	Notes                        []string
}

SwitchResult reports the outcome of ApplyProfileSwitch. As with DeployResult/UninstallResult, every Notes entry is always recorded - there is no verbosity concept in core.

  • Notes holds every diagnostic doProfileSwitch only printed under --verbose: failed Uninstall/SetModEnabled during the disable loop, failed Install/SetModEnabled during the enable loop, and a failed UpsertMod during the install loop. Each entry already carries its historical "Warning: " prefix, matching doProfileSwitch's exact wording; a caller wanting byte-identical output should print each entry to stdout ONLY under --verbose, e.g. `fmt.Printf(" %s\n", n)` (disable/enable loop notes) or `fmt.Printf(" %s\n", n)` (the install loop's profile-update note, one indent level deeper).

Every Notes entry is ALSO reported via the progress callback at the exact point it is appended (SwitchDisableNote/SwitchEnableNote/SwitchInstallNote - see each DeployPhase constant's doc comment), with Detail equal to the slice entry verbatim.

On error, the returned result carries any diagnostics/counts accumulated before the failure; callers should surface them alongside the error.

type UninstallOptions added in v1.11.0

type UninstallOptions struct {
	KeepCache bool // --keep-cache: skip deleting the mod's cache entry

	// Hook plumbing, mirroring BatchOptions. Hooks and/or HookRunner may be
	// nil to skip hook execution entirely (e.g. --no-hooks).
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	Force       bool // continue past a failing uninstall.before_* hook (warn instead of fail)

}

UninstallOptions configures UninstallMod.

type UninstallResult added in v1.11.0

type UninstallResult struct {
	Warnings []string // unconditional, stderr, audience: operator/always-visible
	Notes    []string // --verbose-gated, stdout, audience: diagnostic detail
}

UninstallResult reports the outcome of UninstallMod. Every entry in both slices below is always recorded — UninstallMod has no verbosity concept — but the two slices carry different display contracts for callers to honor (this is the convention Tasks 3-4 should follow too):

  • Warnings holds diagnostics the pre-extraction CLI printed unconditionally to stderr regardless of --verbose (hook failures: uninstall.before_* when Force is set, and uninstall.after_*, which is always non-fatal). Callers should print each entry to stderr, unconditionally, e.g. `fmt.Fprintf(os.Stderr, "Warning: %v\n", w)`.
  • Notes holds operational diagnostics the pre-extraction CLI only printed under --verbose (undeploy failure, cache-delete failure, and a failure to remove the mod from the profile). Each entry already carries its historical prefix word baked into the text ("Warning: " for undeploy/cache-delete, "Note: " for the profile-removal message, matching the pre-extraction CLI's exact wording for each), so a caller that wants byte-identical pre-extraction output should print each entry to stdout ONLY under --verbose, verbatim, e.g. `fmt.Printf(" %s\n", n)`.

On error, the returned result carries any diagnostics accumulated before the failure; callers should surface them alongside the error.

type UninstalledModResult added in v0.12.0

type UninstalledModResult struct {
	domain.Mod
}

UninstalledModResult is a successfully uninstalled mod (wraps domain.Mod for batch result)

type UpdateApplyResult added in v1.12.0

type UpdateApplyResult struct {
	Applied  []string
	Warnings []string
	Notes    []string
}

UpdateApplyResult reports the outcome of ApplyUpdate. As with DeployResult/UninstallResult/SwitchResult/InstallResult, every entry below is always recorded - there is no verbosity concept in core.

  • Applied holds a single "<name> <old version> → <new version>" entry (matching what the CLI prints, e.g. "SkyUI 5.1 → 5.2") once the WHOLE sequence - download, hooks, Replace, and all three DB/profile writes - has succeeded. Empty on any failure; ApplyUpdate applies exactly one domain.Update per call (the CLI's own update loop calls it once per mod), so this is never more than a single entry.
  • Warnings holds diagnostics applyUpdate printed unconditionally: uninstall.before_each/install.before_each (when forced), and uninstall.after_each/install.after_each hook failures (always non-fatal). Callers should print each entry to stderr, unconditionally, e.g. `fmt.Fprintf(os.Stderr, "Warning: %v\n", w)`.
  • Notes holds the sole diagnostic applyUpdate only printed under --verbose: a failed SetModLinkMethod, with the historical "Warning: " prefix baked into the text already (matching applyUpdate's exact wording); a caller wanting byte-identical output should print it to stdout ONLY under --verbose, e.g. `fmt.Printf(" %s\n", n)`.

Every entry in both slices is ALSO reported via the progress callback at the exact point it is appended (UpdateBeforeEachForced/UpdateWarning/ UpdateNote - see each DeployPhase constant's doc comment), with Detail equal to the slice entry verbatim.

On error, the returned result carries any diagnostics accumulated before the failure; callers should surface them alongside the error.

type UpdateOptions added in v1.12.0

type UpdateOptions struct {
	// Hook plumbing, mirroring UninstallOptions/DeployOptions/InstallOptions.
	// Hooks and/or HookRunner may be nil to skip hook execution entirely
	// (e.g. --no-hooks).
	Hooks       *ResolvedHooks
	HookRunner  *HookRunner
	HookContext HookContext
	// Force: continue past a failing uninstall.before_each/install.before_each
	// hook (warn instead of fail), matching applyUpdate's own --force gate.
	Force bool
}

UpdateOptions configures ApplyUpdate. Unlike InstallOptions/DeployOptions, there is no before_all/after_all hook plumbing at all - applyUpdate never ran that pair (see ApplyUpdate's doc comment) - so Force here gates ONLY the two before_each hooks (uninstall.before_each for the old version, install.before_each for the new one), matching applyUpdate's own near-identical Force checks exactly.

type UpdateSkips added in v1.16.0

type UpdateSkips struct {
	Pinned int
	Local  int
}

UpdateSkips counts the mods CheckUpdates will filter out, by reason. The two are reported separately because the remedies differ: a pin can be lifted, a local mod can never be checked.

func CountUpdateSkips added in v1.16.0

func CountUpdateSkips(installed []domain.InstalledMod) UpdateSkips

CountUpdateSkips tallies why CheckUpdates will skip mods in installed. A mod that is both pinned and local counts once, as pinned, so Total never exceeds len(installed).

func (UpdateSkips) Total added in v1.16.0

func (s UpdateSkips) Total() int

Total is the number of mods that will not be checked at all.

type Updater

type Updater struct {
	// contains filtered or unexported fields
}

Updater checks for and applies mod updates

func NewUpdater

func NewUpdater(registry *source.Registry) *Updater

NewUpdater creates a new updater

func (*Updater) CheckUpdates

func (u *Updater) CheckUpdates(ctx context.Context, game *domain.Game, installed []domain.InstalledMod) ([]domain.Update, error)

CheckUpdates checks for available updates for installed mods. game supplies the source-ID mapping: installed rows persist the lmm game ID, but sources like NexusMods address games by their own domain, so each source's batch is translated via game.SourceIDs before the call (empty mapping = keep the lmm id, matching the search-side semantics in Service.SearchMods/GetMod).

func (*Updater) GetAutoUpdateMods

func (u *Updater) GetAutoUpdateMods(installed []domain.InstalledMod) []domain.InstalledMod

GetAutoUpdateMods filters installed mods to those with auto-update enabled

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL