Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but

**Non-interactive behavior**

- R6: When stdin is not an interactive TTY (CI, agents, redirected I/O), default mode is **warn and continue**: emit a structured `WARN:` line per detected FOMOD archive and proceed without persisting dismiss state.
- R6: When stdin is not an interactive TTY (CI, agents, redirected I/O), default mode is **warn and continue**: emit a structured `WARN:` line per detected FOMOD archive, persist `MarkWarned` so the warning is not repeated, and proceed without configuring. **Warned does not satisfy the validation/install gate** (see R16–R21).
- R7: Global `--fomod-skip` (and equivalent env/settings) persists `MarkDismissed` for archives handled under that skip policy in the session.
- R8: Non-interactive mode is configurable globally via environment variable, CLI startup/global flags, and ModSync settings dialog; v1 ships the `warn-continue` mode but the resolution pipeline must accept future modes without redesign.

Expand All @@ -54,6 +54,15 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but
- R14: Applying wizard selections must not merge plugins from steps that are no longer visible after condition-flag changes (hidden-step parity with visible validation).
- R15: Re-configuring the same archive updates existing option selections and replaces superseded FOMOD-generated instructions instead of silently skipping duplicate option GUIDs.

**FOMOD validation gate (configured-only)**

- R16: On all validate and install entry points (GUI wizard Validate, Getting Started Validate, GUI install start, CLI `validate`, CLI `install`), scan **selected** components plus transitive **hard dependencies** for downloaded archives where `FomodArchiveProbe` detects FOMOD.
- R17: If a detected FOMOD archive's `fomodPromptStatus` is not `configured`, emit a **validation error** that blocks proceed — not a log-only warning.
- R18: `dismissed`, `warned`, and missing status all fail the gate; only `configured` passes.
- R19: Post-download dismiss (`MarkDismissed`), warn-continue (`MarkWarned`), and `--fomod-skip` may still affect download-time prompting but **do not** clear the gate.
- R20: Error text names the mod and archive and points users to Fetch Downloads / post-download FOMOD wizard (no in-validate configure button in this slice).
- R21: Gate runs inside `InstallationValidationPipeline` (shared CLI/GUI) and `InstallationService.InstallAllSelectedComponentsAsync` (install safety net); wizard `InstallStartPage` blocks Next when gate fails.

## Success Criteria

- Headless `install -d` against a fixture with a FOMOD archive prompts on TTY, applies choices, and proceeds to validation with merged options.
Expand All @@ -62,6 +71,8 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but
- `--fomod-choices` configures a FOMOD mod without TTY and marks configured state.
- GUI **Fetch Downloads** behavior unchanged after GUI delegates to the Core orchestrator.
- `docs/knowledgebase/agent-action-parity.md` documents post-download FOMOD configure for CLI.
- Validate/install fail with a clear error when a selected mod has a downloaded FOMOD archive that is not `configured`; passing after full FOMOD wizard merge.
- Dismissed or warned archives still block validate/install until configured.

## Scope Boundaries

Expand All @@ -73,10 +84,10 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but

**Deferred for later**

- Validation blocking when FOMOD choices are unset.
- Plugin images in terminal wizard.
- Download scope alignment so CLI only downloads `IsSelected` components (separate download-system change).
- Additional non-TTY modes beyond `warn-continue` (fail-fast, auto-dismiss-all) once v1 config plumbing exists.
- In-validate **Configure FOMOD** action and `fomod configure` CLI verb (recovery hints in error text only for now).

**Outside this product's identity**

Expand All @@ -89,6 +100,7 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but
- Config precedence: CLI flags > environment variables > `settings.json` > TTY-derived default.
- Selected-only FOMOD scan even when download fetched more components.
- Convert/merge output persistence uses existing `-o` / output path; no silent in-place overwrite of input without documented flag.
- Validation/install gate is **configured-only**; skip/dismiss paths are download-time only.

## Dependencies / Assumptions

Expand Down
3 changes: 2 additions & 1 deletion docs/knowledgebase/fomod-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../pla

- `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing.
- `FomodPostDownloadPromptService` runs after GUI **Fetch Downloads** completes; optional prompt per archive.
- `FomodDownloadPromptState` stores dismissed/configured outcomes in resource handler metadata.
- `FomodDownloadPromptState` stores dismissed/configured/warned outcomes in resource handler metadata.
- `FomodConfigurationGate` blocks validate and install unless every detected FOMOD archive on selected mods (plus hard dependencies) is `configured`; dismiss/skip/warned do not pass the gate.
- `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata.

## Verification
Expand Down
22 changes: 22 additions & 0 deletions src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2760,6 +2760,28 @@ private static async Task LogValidationPipelineOutputAsync(
await Logger.LogAsync("Validation Summary:").ConfigureAwait(false);
await Logger.LogAsync($" Total components validated: {componentCount}").ConfigureAwait(false);
break;
case ValidationPipelineStage.FomodConfiguration:
await Logger.LogAsync("Checking FOMOD configuration...").ConfigureAwait(false);
await Logger.LogAsync(new string('-', 50)).ConfigureAwait(false);
if (stage.Passed)
{
await Logger.LogAsync("✓ All detected FOMOD archives are configured").ConfigureAwait(false);
}
else
{
await Logger.LogErrorAsync(stage.Summary ?? "Unconfigured FOMOD archives detected").ConfigureAwait(false);
foreach (string message in stage.Messages)
{
if (message.StartsWith("ERROR:", StringComparison.Ordinal))
{
await Logger.LogErrorAsync(message.Substring(6).Trim()).ConfigureAwait(false);
}
}
}

await Logger.LogAsync(new string('-', 50)).ConfigureAwait(false);
await Logger.LogAsync().ConfigureAwait(false);
break;
case ValidationPipelineStage.DryRun:
await Logger.LogAsync("\nRunning dry-run validation...").ConfigureAwait(false);
if (pipelineResult.DryRunResult != null)
Expand Down
159 changes: 159 additions & 0 deletions src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2021-2025 ModSync
// Licensed under the Business Source License 1.1 (BSL 1.1).
// See LICENSE.txt file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Linq;

using JetBrains.Annotations;

namespace ModSync.Core.Services.Fomod
{
/// <summary>
/// Blocks validate/install when a downloaded FOMOD archive is not fully configured.
/// Only <see cref="FomodDownloadPromptState.StatusConfigured"/> satisfies the gate.
/// </summary>
public static class FomodConfigurationGate
{
public const string IssueCategory = "FOMOD";

public const string RecoveryHint =
"Run Fetch Downloads and complete the FOMOD installer wizard for this archive, "
+ "or use CLI post-download configure on an interactive terminal.";

public sealed class GateIssue
{
[NotNull]
public ModComponent Component { get; set; }

[NotNull]
public string ArchiveFileName { get; set; }

[CanBeNull]
public string PromptStatus { get; set; }
}

public sealed class GateResult
{
public bool Passed => Issues.Count == 0;

[NotNull]
public List<GateIssue> Issues { get; } = new List<GateIssue>();
}

[NotNull]
public static GateResult Validate(
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents,
[NotNull][ItemNotNull] IEnumerable<ModComponent> seedComponents,
[NotNull] string modDirectory)
{
if (allComponents is null)
{
throw new ArgumentNullException(nameof(allComponents));
}

if (seedComponents is null)
{
throw new ArgumentNullException(nameof(seedComponents));
}

if (string.IsNullOrWhiteSpace(modDirectory))
{
throw new ArgumentException("Mod directory is required.", nameof(modDirectory));
}

var result = new GateResult();
List<ModComponent> scope = ExpandWithHardDependencies(seedComponents, allComponents);

foreach (ModComponent component in scope)
{
foreach (string archivePath in FomodDownloadedArchivePaths.GetPaths(component, modDirectory))
{
if (!FomodArchiveProbe.TryDetectInArchive(archivePath, out _))
{
continue;
}

string archiveFileName = System.IO.Path.GetFileName(archivePath);
string status = FomodDownloadPromptState.GetStatus(component, archiveFileName);
if (string.Equals(status, FomodDownloadPromptState.StatusConfigured, StringComparison.Ordinal))
{
continue;
}

result.Issues.Add(new GateIssue
{
Component = component,
ArchiveFileName = archiveFileName,
PromptStatus = status,
});
}
}

return result;
}

[NotNull]
public static List<ModComponent> ExpandWithHardDependencies(
[NotNull][ItemNotNull] IEnumerable<ModComponent> seedComponents,
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents)
{
var byGuid = allComponents.ToDictionary(c => c.Guid);
var visited = new HashSet<Guid>();
var ordered = new List<ModComponent>();
var queue = new Queue<ModComponent>();

foreach (ModComponent seed in seedComponents)
{
if (seed is null)
{
continue;
}

queue.Enqueue(seed);
}

while (queue.Count > 0)
{
ModComponent component = queue.Dequeue();
if (!visited.Add(component.Guid))
{
continue;
}

ordered.Add(component);

if (component.Dependencies is null)
{
continue;
}

foreach (Guid dependencyGuid in component.Dependencies)
{
if (byGuid.TryGetValue(dependencyGuid, out ModComponent dependency))
{
queue.Enqueue(dependency);
}
}
}

return ordered;
}

[NotNull]
public static string FormatIssueMessage([NotNull] GateIssue issue)
{
if (issue is null)
{
throw new ArgumentNullException(nameof(issue));
}

string statusLabel = string.IsNullOrEmpty(issue.PromptStatus)
? "not configured"
: $"status '{issue.PromptStatus}'";

return $"FOMOD archive '{issue.ArchiveFileName}' is {statusLabel}. {RecoveryHint}";
}
}
}
25 changes: 25 additions & 0 deletions src/ModSync.Core/Services/InstallationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using ModSync.Core.FileSystemUtils;
using ModSync.Core.Installation;
using ModSync.Core.Services.Checkpoints;
using ModSync.Core.Services.Fomod;
using ModSync.Core.Utility;

using Python.Included;
Expand Down Expand Up @@ -955,6 +956,30 @@ private static string FormatHolopatcherError(Exception ex, string errorType)
throw new ArgumentNullException(nameof(allComponents));
}

List<ModComponent> selectedComponents = allComponents.Where(component => component.IsSelected).ToList();
string modDirectory = MainConfig.Instance?.sourcePath?.FullName;
if (!string.IsNullOrWhiteSpace(modDirectory) && System.IO.Directory.Exists(modDirectory))
{
FomodConfigurationGate.GateResult fomodGate = FomodConfigurationGate.Validate(
allComponents,
selectedComponents,
modDirectory);
if (!fomodGate.Passed)
{
await Logger.LogErrorAsync(
"Installation blocked: one or more FOMOD archives are not configured."
).ConfigureAwait(false);
foreach (FomodConfigurationGate.GateIssue issue in fomodGate.Issues)
{
await Logger.LogErrorAsync(
$"[{issue.Component.Name}] {FomodConfigurationGate.FormatIssueMessage(issue)}"
).ConfigureAwait(false);
}

return ModComponent.InstallExitCode.InvalidOperation;
}
}

var coordinator = new InstallCoordinator();
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using JetBrains.Annotations;

using ModSync.Core.Services.FileSystem;
using ModSync.Core.Services.Fomod;

namespace ModSync.Core.Services.Validation
{
Expand Down Expand Up @@ -149,6 +150,28 @@ public static async Task<ValidationPipelineResult> RunAsync(
}
}

if (!options.SkipFomodConfigurationGate)
{
cancellationToken.ThrowIfCancellationRequested();
step++;
progress?.Invoke(ValidationPipelineStage.FomodConfiguration, step, totalSteps, "Checking FOMOD configuration...");
ValidationPipelineStageResult fomodStage = RunFomodConfigurationStage(
componentsToValidate,
allComponents,
options);
result.Stages.Add(fomodStage);
if (!fomodStage.Passed)
{
result.HasCriticalErrors = true;
result.ErrorCount += fomodStage.Messages.Count;
result.IsSuccess = false;
}
else
{
result.PassedCount++;
}
}

bool runDryRun = options.DryRun || options.DryRunOnly;
if (runDryRun)
{
Expand Down Expand Up @@ -224,6 +247,11 @@ private static int CountStages(ValidationPipelineOptions options)
count++;
}

if (!options.SkipFomodConfigurationGate)
{
count++;
}

if (options.DryRun || options.DryRunOnly)
{
count++;
Expand Down Expand Up @@ -391,6 +419,48 @@ private static (ValidationPipelineStageResult stage, int errors, int warnings) R
return (stage, errors, warnings);
}

[NotNull]
private static ValidationPipelineStageResult RunFomodConfigurationStage(
[NotNull][ItemNotNull] List<ModComponent> componentsToValidate,
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents,
[NotNull] ValidationPipelineOptions options)
{
var stage = new ValidationPipelineStageResult
{
Stage = ValidationPipelineStage.FomodConfiguration,
Passed = true,
};

MainConfig config = options.MainConfig ?? MainConfig.Instance;
string modDirectory = config?.sourcePath?.FullName;
if (string.IsNullOrWhiteSpace(modDirectory) || !System.IO.Directory.Exists(modDirectory))
{
stage.Summary = "Skipped FOMOD configuration check (mod directory not set).";
return stage;
}

FomodConfigurationGate.GateResult gateResult = FomodConfigurationGate.Validate(
allComponents,
componentsToValidate,
modDirectory);

if (gateResult.Passed)
{
stage.Summary = "All detected FOMOD archives are configured.";
return stage;
}

stage.Passed = false;
stage.Summary = $"{gateResult.Issues.Count} unconfigured FOMOD archive(s).";
foreach (FomodConfigurationGate.GateIssue issue in gateResult.Issues)
{
stage.Messages.Add(
$"ERROR: {issue.Component.Name}: {FomodConfigurationGate.FormatIssueMessage(issue)}");
}

return stage;
}

[NotNull]
private static async Task<(ValidationPipelineStageResult stage, DryRunValidationResult dryRunResult)> RunDryRunStageAsync(
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public sealed class ValidationPipelineOptions
/// <summary>Skip per-component archive validation (tests that only need graph checks).</summary>
public bool SkipComponentArchiveValidation { get; set; }

/// <summary>Skip FOMOD configured-only gate (tests without FOMOD fixtures).</summary>
public bool SkipFomodConfigurationGate { get; set; }

[CanBeNull]
public MainConfig MainConfig { get; set; }

Expand Down
Loading
Loading