diff --git a/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md b/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md index 4f144f82..be8eedb8 100644 --- a/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md +++ b/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md @@ -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 | diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index e2162b03..d898b25e 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -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; @@ -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")] @@ -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) diff --git a/src/ModSync.Core/Services/Installation/ManagedInstallCliOverrides.cs b/src/ModSync.Core/Services/Installation/ManagedInstallCliOverrides.cs new file mode 100644 index 00000000..a56e7ef7 --- /dev/null +++ b/src/ModSync.Core/Services/Installation/ManagedInstallCliOverrides.cs @@ -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 +{ + /// + /// Applies CLI --managed / --no-managed / --profile overrides onto + /// loaded without persisting them (process-local for the install). + /// + public static class ManagedInstallCliOverrides + { + /// + /// Merges optional CLI overrides into in place. + /// + /// Error message when flags conflict; otherwise null. + [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 (or activate a profile in Settings)."; + } + + return null; + } + + /// + /// Resolves the managed-enabled override for : + /// true/false when CLI forces a value; null to keep settings.json. + /// + [CanBeNull] + public static bool? ResolveManagedOverride(bool enableManaged, bool disableManaged) + { + if (enableManaged && disableManaged) + { + return null; + } + + if (enableManaged) + { + return true; + } + + if (disableManaged) + { + return false; + } + + return null; + } + } +} diff --git a/src/ModSync.Core/Services/InstallationService.cs b/src/ModSync.Core/Services/InstallationService.cs index 7e4d6591..2de54ed1 100644 --- a/src/ModSync.Core/Services/InstallationService.cs +++ b/src/ModSync.Core/Services/InstallationService.cs @@ -43,12 +43,23 @@ public class InstallationService /// private static async Task RunWithManagedInstallSessionAsync( [NotNull] Func> 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; @@ -968,7 +979,9 @@ private static string FormatHolopatcherError(Exception ex, string errorType) public static async Task InstallSingleComponentAsync( [NotNull] ModComponent component, [NotNull][ItemNotNull] IReadOnlyList allComponents, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + [CanBeNull] string profileOverride = null, + bool? managedDeploymentOverride = null) { if (component is null) { @@ -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 InstallSingleComponentCoreAsync( @@ -1033,7 +1047,8 @@ await Logger.LogErrorAsync( [NotNull][ItemNotNull] List allComponents, [CanBeNull] Action progressCallback = null, CancellationToken cancellationToken = default, - [CanBeNull] string profileOverride = null) + [CanBeNull] string profileOverride = null, + bool? managedDeploymentOverride = null) { if (allComponents is null) { @@ -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 InstallAllSelectedComponentsCoreAsync( @@ -1219,14 +1235,20 @@ await Logger.LogWarningAsync( [NotNull][ItemNotNull] IReadOnlyList allComponents, [CanBeNull] Action 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); } } diff --git a/src/ModSync.Tests/ManagedInstallCliOverridesTests.cs b/src/ModSync.Tests/ManagedInstallCliOverridesTests.cs new file mode 100644 index 00000000..fb8bb5ed --- /dev/null +++ b/src/ModSync.Tests/ManagedInstallCliOverridesTests.cs @@ -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")); + } + } +}