Skip to content
Merged
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 @@ -354,7 +354,7 @@ Gate itself remains **U7 / #170** until that PR lands — do not bypass it when

| Unit | Status |
|------|--------|
| U3 CLI `--profile` + shared session polish | Deferred |
| U3 CLI `--profile` + shared session polish | In PR (CLI `--managed`/`--no-managed`/`--profile`) |
| U4 Managed VFS / validation parity | Deferred |
| U5 Uninstall / purge GUI | Deferred |
| U6 Patcher provenance (ImmutableCheckpoint) | Deferred |
Expand Down
44 changes: 43 additions & 1 deletion src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
using ModSync.Core.Services;
using ModSync.Core.Services.Download;
using ModSync.Core.Services.Fomod;
using ModSync.Core.Services.Installation;
using ModSync.Core.Services.Settings;
using ModSync.Core.Services.Validation;
using ModSync.Core.Utility;

Expand Down Expand Up @@ -652,6 +654,15 @@ public class InstallOptions : BaseOptions

[Option("ignore-errors", Required = false, Default = false, HelpText = "Ignore dependency resolution errors and attempt to load components in the best possible order")]
public bool IgnoreErrors { get; set; }

[Option("managed", Required = false, Default = false, HelpText = "Force managed deployment for this install (requires --profile or an active profile in settings)")]
public bool Managed { get; set; }

[Option("no-managed", Required = false, Default = false, HelpText = "Force classic (non-managed) install for this run, ignoring settings.json managedDeploymentEnabled")]
public bool NoManaged { get; set; }

[Option("profile", Required = false, HelpText = "Install profile name for managed deployment (overrides activeProfileName for this run)")]
public string Profile { get; set; }
}

[Verb("set-nexus-api-key", HelpText = "Set and validate your Nexus Mods API key")]
Expand Down Expand Up @@ -3267,14 +3278,45 @@ await LogValidationPipelineOutputAsync(
await Logger.LogAsync("Starting installation...").ConfigureAwait(false);
await Logger.LogAsync(new string('=', 50)).ConfigureAwait(false);

ModSyncSettings managedSettings = ModSyncSettings.Load();
string managedCliError = ManagedInstallCliOverrides.Apply(
managedSettings,
opts.Managed,
opts.NoManaged,
opts.Profile);
if (managedCliError != null)
{
await Logger.LogErrorAsync(managedCliError).ConfigureAwait(false);
return 1;
}

bool? managedOverride = ManagedInstallCliOverrides.ResolveManagedOverride(opts.Managed, opts.NoManaged);
string profileOverride = string.IsNullOrWhiteSpace(opts.Profile) ? null : opts.Profile.Trim();
if (managedSettings.ManagedDeploymentEnabled)
{
await Logger.LogAsync(
$"Managed deployment enabled for profile '{managedSettings.ActiveProfileName}'."
).ConfigureAwait(false);
}

ModComponent.InstallExitCode exitCode = await InstallationService.InstallAllSelectedComponentsAsync(
components,
async (currentIndex, total, componentName) =>
{
await Logger.LogAsync($"[{currentIndex + 1}/{total}] Installing: {componentName}").ConfigureAwait(false);
}
},
cancellationToken: default,
profileOverride: profileOverride,
managedDeploymentOverride: managedOverride
).ConfigureAwait(false);

if (InstallationService.LastManagedInstallResult != null)
{
await Logger.LogAsync(
ManagedInstallSummaryFormatter.FormatWizardSummary(InstallationService.LastManagedInstallResult)
).ConfigureAwait(false);
}

await Logger.LogAsync(new string('=', 50)).ConfigureAwait(false);

if (exitCode == ModComponent.InstallExitCode.Success)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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 JetBrains.Annotations;

using ModSync.Core.Services.Settings;

namespace ModSync.Core.Services.Installation
{
/// <summary>
/// Applies CLI <c>--managed</c> / <c>--no-managed</c> / <c>--profile</c> overrides onto
/// loaded <see cref="ModSyncSettings"/> without persisting them (process-local for the install).
/// </summary>
public static class ManagedInstallCliOverrides
{
/// <summary>
/// Merges optional CLI overrides into <paramref name="settings"/> in place.
/// </summary>
/// <returns>Error message when flags conflict; otherwise <c>null</c>.</returns>
[CanBeNull]
public static string Apply(
[NotNull] ModSyncSettings settings,
bool enableManaged,
bool disableManaged,
[CanBeNull] string profileName)
{
if (settings is null)
{
throw new ArgumentNullException(nameof(settings));
}

if (enableManaged && disableManaged)
{
return "Cannot combine --managed and --no-managed.";
}

if (enableManaged)
{
settings.ManagedDeploymentEnabled = true;
}
else if (disableManaged)
{
settings.ManagedDeploymentEnabled = false;
}

if (!string.IsNullOrWhiteSpace(profileName))
{
settings.ActiveProfileName = profileName.Trim();
}

if (settings.ManagedDeploymentEnabled && string.IsNullOrWhiteSpace(settings.ActiveProfileName))
{
return ManagedInstallSession.MissingActiveProfileMessage
+ " Pass --profile <name> (or activate a profile in Settings).";
}

return null;
}

/// <summary>
/// Resolves the managed-enabled override for <see cref="InstallationService"/>:
/// <c>true</c>/<c>false</c> when CLI forces a value; <c>null</c> to keep settings.json.
/// </summary>
[CanBeNull]
public static bool? ResolveManagedOverride(bool enableManaged, bool disableManaged)
{
if (enableManaged && disableManaged)
{
return null;
}

if (enableManaged)
{
return true;
}

if (disableManaged)
{
return false;
}

return null;
}
}
}
36 changes: 29 additions & 7 deletions src/ModSync.Core/Services/InstallationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,23 @@ public class InstallationService
/// </summary>
private static async Task<ModComponent.InstallExitCode> RunWithManagedInstallSessionAsync(
[NotNull] Func<Task<ModComponent.InstallExitCode>> installAction,
[CanBeNull] string profileOverride = null)
[CanBeNull] string profileOverride = null,
bool? managedDeploymentOverride = null)
{
ManagedInstallSession managedSession = null;
try
{
ModSyncSettings settings = ModSyncSettings.Load();
if (managedDeploymentOverride.HasValue)
{
settings.ManagedDeploymentEnabled = managedDeploymentOverride.Value;
}

if (!string.IsNullOrWhiteSpace(profileOverride))
{
settings.ActiveProfileName = profileOverride.Trim();
}

var profileService = new ProfileService(ModSyncSettings.GetSettingsDirectory());
managedSession = ManagedInstallSession.TryCreate(settings, profileService, profileOverride);
ManagedInstallSession.Current = managedSession;
Expand Down Expand Up @@ -968,7 +979,9 @@ private static string FormatHolopatcherError(Exception ex, string errorType)
public static async Task<ModComponent.InstallExitCode> InstallSingleComponentAsync(
[NotNull] ModComponent component,
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
[CanBeNull] string profileOverride = null,
bool? managedDeploymentOverride = null)
{
if (component is null)
{
Expand All @@ -982,7 +995,8 @@ private static string FormatHolopatcherError(Exception ex, string errorType)

return await RunWithManagedInstallSessionAsync(
() => InstallSingleComponentCoreAsync(component, allComponents, cancellationToken),
profileOverride: null).ConfigureAwait(false);
profileOverride,
managedDeploymentOverride).ConfigureAwait(false);
}

private static async Task<ModComponent.InstallExitCode> InstallSingleComponentCoreAsync(
Expand Down Expand Up @@ -1033,7 +1047,8 @@ await Logger.LogErrorAsync(
[NotNull][ItemNotNull] List<ModComponent> allComponents,
[CanBeNull] Action<int, int, string> progressCallback = null,
CancellationToken cancellationToken = default,
[CanBeNull] string profileOverride = null)
[CanBeNull] string profileOverride = null,
bool? managedDeploymentOverride = null)
{
if (allComponents is null)
{
Expand All @@ -1042,7 +1057,8 @@ await Logger.LogErrorAsync(

return await RunWithManagedInstallSessionAsync(
() => InstallAllSelectedComponentsCoreAsync(allComponents, progressCallback, cancellationToken),
profileOverride).ConfigureAwait(false);
profileOverride,
managedDeploymentOverride).ConfigureAwait(false);
}

private static async Task<ModComponent.InstallExitCode> InstallAllSelectedComponentsCoreAsync(
Expand Down Expand Up @@ -1219,14 +1235,20 @@ await Logger.LogWarningAsync(
[NotNull][ItemNotNull] IReadOnlyList<ModComponent> allComponents,
[CanBeNull] Action<int, int, string> progressCallback = null,
CancellationToken cancellationToken = default,
[CanBeNull] string profileOverride = null)
[CanBeNull] string profileOverride = null,
bool? managedDeploymentOverride = null)
{
if (allComponents is null)
{
throw new ArgumentNullException(nameof(allComponents));
}

return InstallAllSelectedComponentsAsync(allComponents.ToList(), progressCallback, cancellationToken, profileOverride);
return InstallAllSelectedComponentsAsync(
allComponents.ToList(),
progressCallback,
cancellationToken,
profileOverride,
managedDeploymentOverride);
}

}
Expand Down
96 changes: 96 additions & 0 deletions src/ModSync.Tests/ManagedInstallCliOverridesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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 ModSync.Core.Services.Installation;
using ModSync.Core.Services.Settings;

using NUnit.Framework;

namespace ModSync.Tests
{
[TestFixture]
public sealed class ManagedInstallCliOverridesTests
{
[Test]
public void Apply_EnableManaged_SetsFlagAndRequiresProfile()
{
var settings = new ModSyncSettings();
string error = ManagedInstallCliOverrides.Apply(settings, enableManaged: true, disableManaged: false, profileName: null);

Assert.That(settings.ManagedDeploymentEnabled, Is.True);
Assert.That(error, Does.Contain("no active profile"));
}

[Test]
public void Apply_EnableManagedWithProfile_Succeeds()
{
var settings = new ModSyncSettings { ActiveProfileName = "Old" };
string error = ManagedInstallCliOverrides.Apply(
settings,
enableManaged: true,
disableManaged: false,
profileName: "CLI Profile");

Assert.That(error, Is.Null);
Assert.That(settings.ManagedDeploymentEnabled, Is.True);
Assert.That(settings.ActiveProfileName, Is.EqualTo("CLI Profile"));
}

[Test]
public void Apply_DisableManaged_ClearsManagedEvenIfSettingsHadItOn()
{
var settings = new ModSyncSettings
{
ManagedDeploymentEnabled = true,
ActiveProfileName = "Alpha",
};

string error = ManagedInstallCliOverrides.Apply(
settings,
enableManaged: false,
disableManaged: true,
profileName: null);

Assert.That(error, Is.Null);
Assert.That(settings.ManagedDeploymentEnabled, Is.False);
}

[Test]
public void Apply_ConflictingFlags_ReturnsError()
{
var settings = new ModSyncSettings();
string error = ManagedInstallCliOverrides.Apply(
settings,
enableManaged: true,
disableManaged: true,
profileName: "X");

Assert.That(error, Does.Contain("--managed").And.Contain("--no-managed"));
}

[Test]
public void ResolveManagedOverride_MapsFlags()
{
Assert.That(ManagedInstallCliOverrides.ResolveManagedOverride(true, false), Is.True);
Assert.That(ManagedInstallCliOverrides.ResolveManagedOverride(false, true), Is.False);
Assert.That(ManagedInstallCliOverrides.ResolveManagedOverride(false, false), Is.Null);
Assert.That(ManagedInstallCliOverrides.ResolveManagedOverride(true, true), Is.Null);
}

[Test]
public void Apply_ProfileOnly_UpdatesNameWithoutForcingManaged()
{
var settings = new ModSyncSettings { ManagedDeploymentEnabled = false };
string error = ManagedInstallCliOverrides.Apply(
settings,
enableManaged: false,
disableManaged: false,
profileName: "Beta");

Assert.That(error, Is.Null);
Assert.That(settings.ManagedDeploymentEnabled, Is.False);
Assert.That(settings.ActiveProfileName, Is.EqualTo("Beta"));
}
}
}
Loading