Skip to content

Deploy output presents merged/compiled mods as individually deployed, and never names the merged pak #255

Description

@dyoung522

Summary

On a DeployCompile game, lmm deploy reports per-mod success for mods that individually deployed nothing, and never mentions the one artifact that actually reached the game directory. The output asserts something untrue about where files went.

❯ lmm -g icarus deploy
Deploying 2 mod(s) using symlink...

  ✓ Donovan's More Points
  ✓ laanp-Combined_QOL

Deployed: 2

Both lines are mods that linked zero files. The real artifact — a single profile-level zzz_LMM_Merged_P.pak — is never named. using symlink compounds it: the link method is real, but it applies to the merged pak's single deployment, not to either listed mod.

Why it happens

  • DeployProfile's loop calls installer.Install per mod, then unconditionally does result.Deployed++ and emits DeployDeployed (internal/core/flows.go:1856-1859).
  • Installer.Install resolves deployableFiles and loops over it (internal/core/installer.go:43-82). Zero files → zero iterations → return nil. Success, indistinguishable from a real deployment.
  • For an exmodz mod deployableFiles narrows to claimed manifest members once a retained source exists (internal/core/deployable.go:75-83), and an exmodz entry has none. internal/core/verify.go:314-318 already documents the invariant: "a DeployCompile game's .exmodz mod is ingested as validate+retain ONLY - it has zero deployment members of its own by design."
  • The merged pak is built after the loop by syncMergedPak (internal/core/flows.go:1885), which emits only warnings — there is no positive progress event for it, so the CLI currently has nothing to render even if it wanted to.

So Deployed: 2 counts two no-op installs and omits the only real deployment.

A compile profile is three-way, not two-way

A blanket "compiled" label would be its own lie. The classes:

Class Individually deployed? Label
exmodz, or a pak that converted No — the merged pak carries it merged
pak that failed conversion, or convert_paks: false Yes, raw deployed raw
ordinary loose-file mod in the same profile Yes deployed

The gate separating class 1 from class 2 is participating := game.ConvertPaks && mod.ConvertPaks && !failed (internal/core/merged_pak.go:424).

Data already available — no new computation

  • Service.MergedPakOutcomes(game, profile) ([]MergedFingerprintEntry, bool) (internal/core/merged_pak.go:620) — public, already consumed by verify and status; per-mod Kind / Converted / FailReason.
  • core.HasRetainedCompileSource(...) (internal/core/verify_helpers.go:26) — public; exactly the "merge participant with no members of its own" predicate.
  • enabledMergeSources — cheap (one dir read plus small stats; already the sync fast-path cost) and yields kind + participation without the merge having run yet.

Decided approach: option (b) — classify before the loop, correct in the footer

Settled 2026-08-08. Do not re-litigate.

Per-mod lines print during the loop, but syncMergedPak runs after it — so at time we know a mod will merge, not whether a pak's conversion succeeded. Option (b) resolves that as follows:

  • Classify each mod before the deploy loop using enabledMergeSources, and label merge participants inline as (merged).
  • Let the existing post-sync conversion-failure warning carry the correction for the one optimistic case (a pak labeled (merged) that then fails conversion and falls back to raw).
  • Add a post-sync footer that names the artifact and reports participant counts. This is where accuracy lands, since it runs after syncMergedPak and can report raw fallbacks and conversion failures correctly.

Target output:

Deploying 2 mod(s) — compile mode (merged pak)...

  ✓ Donovan's More Points        (merged)
  ✓ laanp-Combined_QOL           (merged)

Merged 2 mod(s) → zzz_LMM_Merged_P.pak
Deployed: 2

Exact wording is not prescribed here — the requirement is that the per-mod lines stop reading as individual deployments and the merged artifact is named.

Rejected alternatives, recorded so they aren't rediscovered:

  • Read the stored fingerprint before the loop — wrong on first deploy and after any input change (the fingerprint describes the previous sync, not this one).
  • Buffer per-mod lines and flush them after sync — fully accurate, but breaks the deliberate "print at the exact point of occurrence" contract documented at cmd/lmm/deploy.go:136-144, and loses live feedback on slow deploys.

Keep Deployed: N counting merge participants

They are deployed, via the merged pak; the labels carry the nuance. Redefining the count is a larger behavioral break than changing the prose around it, and Deployed:/Failed: is load-bearing in existing tests.

Scope

  • New DeployPhase value(s): a merge-complete event carrying participant count and artifact name. The enum is explicitly designed to be extended rather than forked (internal/core/flows.go:518-521).
  • DeployProgress gains a per-mod classification field, set on DeployDeployed.
  • Emit the merge event after syncMergedPak succeeds (internal/core/flows.go:1885-1894) — today only its warnings surface. Preserve the invariant documented at cmd/lmm/deploy.go:136-141 that every Warnings/Notes entry has exactly one corresponding event, so nothing double-prints.
  • Render in cmd/lmm/deploy.go: header (:133), per-mod line (:187-188), summary (:215-219).
  • TUI parity (standing directive): the TUI is currently worse off — coreProvider.DeployProfile passes nil for progress (internal/tui/service_core.go:810), so it has no event stream at all and shows only Deployed 2 mod(s). It needs the counts from the result, not from events, which likely means a small additive DeployResult field rather than event handling. Note the TUI's status line is one row and already collapses multi-warning outcomes (TUI: multi-warning outcomes collapse to "(N warnings)" with no way to read them — Icarus asset conflicts are lost #253), so the merge readout must fit the Message rather than ride along as a warning.

Risk: low

  • Additive, and gated to DeployMode == DeployCompile — non-compile output is untouched.
  • deploy has no --json, so there is no output contract to version.
  • Every existing deploy test uses a non-compile (symlink) game and asserts via Contains (cmd/lmm/deploy_test.go:164, :167), so no existing test needs to change.
  • Main cost is new coverage on the compile path, where fixtures already exist in internal/core/service_icarus_compile_test.go and cmd/lmm/*_compile_test.go.

Acceptance

  • On a compile game, deploy output names the merged artifact and does not present merge participants as individually deployed.
  • A pak that fell back to raw deploy (conversion failed, or convert_paks: false) is distinguishable in the output from a merged participant.
  • An ordinary loose-file mod in a compile profile still reads as a normal individual deployment.
  • Non-compile game output is byte-identical to today.
  • The TUI reports the merge too, within its one-row status line.
  • Tests cover all three mod classes on the compile path, plus a conversion-failure case where the inline (merged) label is corrected by the footer/warning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:clicmd/lmm - commands, flags, output formattingarea:coreinternal/core - flows, install/update/deploy orchestrationarea:tuiinternal/tui - screens, keys, renderingbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions