diff --git a/src/dnvm/CommandLineArguments.cs b/src/dnvm/CommandLineArguments.cs index c0d4910..68837f5 100644 --- a/src/dnvm/CommandLineArguments.cs +++ b/src/dnvm/CommandLineArguments.cs @@ -14,9 +14,6 @@ namespace Dnvm; [Command("dnvm", Summary = "Install and manage .NET SDKs.")] public partial record DnvmArgs { - [CommandOption("--enable-dnvm-previews", Description = "Enable dnvm previews.")] - public bool? EnableDnvmPreviews { get; init; } - [CommandGroup("command")] public DnvmSubCommand? SubCommand { get; init; } } @@ -246,7 +243,7 @@ public static class CommandLineArguments { case CmdLine.ParsedArgsOrHelpInfos.Parsed(var value): dnvmCmd = value; - if (dnvmCmd.EnableDnvmPreviews is null && dnvmCmd.SubCommand is null) + if (dnvmCmd.SubCommand is null) { // Empty command is a help request. console.WriteLine(CmdLine.GetHelpText(includeHelp: true)); diff --git a/src/dnvm/DnvmConfig.cs b/src/dnvm/DnvmConfig.cs new file mode 100644 index 0000000..b66c791 --- /dev/null +++ b/src/dnvm/DnvmConfig.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using Serde; +using Serde.Json; +using Zio; +using Zio.FileSystems; + +namespace Dnvm; + +/// +/// Represents the dnvm configuration file. +/// +[GenerateSerde] +public sealed partial record DnvmConfig +{ + public static readonly DnvmConfig Default = new(); + + /// + /// Whether to enable dnvm preview releases in the update command. + /// + public bool PreviewsEnabled { get; init; } = false; +} + +/// +/// Provides access to the dnvm configuration file. +/// +public sealed class DnvmConfigFile +{ + private const string ConfigFileName = "dnvmConfig.json"; + + // Allow tests to override the config directory + public static string? TestConfigDirectory { get; set; } + + private readonly string _configDirectory; + + public DnvmConfigFile() : this(GetConfigDirectory()) + { + } + + internal DnvmConfigFile(string configDirectory) + { + _configDirectory = configDirectory; + } + + /// + /// Get the platform-specific config directory path. + /// - Linux: ~/.config/dnvm/ (XDG_CONFIG_HOME/dnvm) + /// - macOS: ~/.config/dnvm/ + /// - Windows: %APPDATA%/dnvm/ + /// + private static string GetConfigDirectory() + { + if (TestConfigDirectory is not null) + { + return TestConfigDirectory; + } + + // Allow tests to override config directory via environment variable + var testOverride = Environment.GetEnvironmentVariable("DNVM_TEST_CONFIG_DIR"); + if (!string.IsNullOrWhiteSpace(testOverride)) + { + return testOverride; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Use XDG_CONFIG_HOME on Linux, defaulting to ~/.config + var xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var configBase = string.IsNullOrWhiteSpace(xdgConfigHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config") + : xdgConfigHome; + return Path.Combine(configBase, "dnvm"); + } + else + { + // On macOS and Windows, use ApplicationData for roaming config files + // This is ~/.config on macOS and %APPDATA% on Windows + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "dnvm"); + } + } + + private IFileSystem GetConfigFileSystem() + { + Directory.CreateDirectory(_configDirectory); + return new SubFileSystem( + DnvmEnv.PhysicalFs, + DnvmEnv.PhysicalFs.ConvertPathFromInternal(_configDirectory)); + } + + private static UPath ConfigPath => UPath.Root / ConfigFileName; + + /// + /// Reads the config file from the platform-specific config directory. + /// Returns the default config if the file does not exist. + /// + public DnvmConfig Read() + { + try + { + var fs = GetConfigFileSystem(); + if (!fs.FileExists(ConfigPath)) + { + return DnvmConfig.Default; + } + + var text = fs.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(text); + } + catch (Exception) + { + // If there's any error reading or parsing the config, return default + return DnvmConfig.Default; + } + } + + /// + /// Writes the config file to the platform-specific config directory. + /// + public void Write(DnvmConfig config) + { + var fs = GetConfigFileSystem(); + var tempFileName = $"{ConfigFileName}.{Path.GetRandomFileName()}.tmp"; + var tempPath = UPath.Root / tempFileName; + + var text = JsonSerializer.Serialize(config); + + // Write to temporary file first + fs.WriteAllText(tempPath, text, Encoding.UTF8); + + // Create backup of existing config if it exists + if (fs.FileExists(ConfigPath)) + { + var backupPath = UPath.Root / $"{ConfigFileName}.backup"; + try + { + // Should not throw if the file doesn't exist + fs.DeleteFile(backupPath); + fs.MoveFile(ConfigPath, backupPath); + } + catch (IOException) + { + // Best effort cleanup - ignore if we can't delete the backup file + } + } + + // Atomic rename operation + fs.MoveFile(tempPath, ConfigPath); + + // Clean up temporary file + try + { + fs.DeleteFile(tempPath); + } + catch (IOException) + { + // Best effort cleanup - ignore if we can't delete the temp file + } + } +} diff --git a/src/dnvm/Manifest.cs b/src/dnvm/Manifest.cs index d5392ed..c361e10 100644 --- a/src/dnvm/Manifest.cs +++ b/src/dnvm/Manifest.cs @@ -41,7 +41,6 @@ public sealed partial record Manifest { public static readonly Manifest Empty = new(); - public bool PreviewsEnabled { get; init; } = false; public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; public EqArray InstalledSdks { get; init; } = []; public EqArray RegisteredChannels { get; init; } = []; diff --git a/src/dnvm/ManifestSchema/ManifestSerialize.cs b/src/dnvm/ManifestSchema/ManifestSerialize.cs index 1541adc..0143c19 100644 --- a/src/dnvm/ManifestSchema/ManifestSerialize.cs +++ b/src/dnvm/ManifestSchema/ManifestSerialize.cs @@ -70,7 +70,6 @@ public static Manifest Convert(this ManifestV9 manifestV9) { return new Manifest { - PreviewsEnabled = manifestV9.PreviewsEnabled, CurrentSdkDir = manifestV9.CurrentSdkDir.Convert(), InstalledSdks = manifestV9.InstalledSdks.SelectAsArray(sdk => new InstalledSdk { @@ -94,7 +93,7 @@ internal static ManifestV9 ConvertToLatest(this Manifest @this) { return new ManifestV9 { - PreviewsEnabled = @this.PreviewsEnabled, + PreviewsEnabled = false, // Always write false since this is now in config file CurrentSdkDir = @this.CurrentSdkDir.ConvertToLatest(), InstalledSdks = @this.InstalledSdks.SelectAsArray(sdk => new InstalledSdkV9 { diff --git a/src/dnvm/Program.cs b/src/dnvm/Program.cs index 1c41dd9..3128c0d 100644 --- a/src/dnvm/Program.cs +++ b/src/dnvm/Program.cs @@ -32,29 +32,13 @@ public static async Task Main(string[] args) using var env = DnvmEnv.CreateDefault(); if (parsedArgs.SubCommand is null) { - if (parsedArgs.EnableDnvmPreviews == true) - { - return await EnableDnvmPreviews(env); - } - else - { - // Help was requested, exit with success. - return 0; - } + // Help was requested, exit with success. + return 0; } return await Dnvm(env, logger, parsedArgs); } - public static async Task EnableDnvmPreviews(DnvmEnv env) - { - using var @lock = await ManifestLock.Acquire(env); - var manifest = await @lock.ReadOrCreateManifest(env); - manifest = manifest with { PreviewsEnabled = true }; - await @lock.WriteManifest(env, manifest); - return 0; - } - internal static async Task Dnvm(DnvmEnv env, Logger logger, DnvmArgs args) { return args.SubCommand switch diff --git a/src/dnvm/UpdateCommand.cs b/src/dnvm/UpdateCommand.cs index 32cd37b..bbe5774 100644 --- a/src/dnvm/UpdateCommand.cs +++ b/src/dnvm/UpdateCommand.cs @@ -126,7 +126,9 @@ public static async Task UpdateSdks( { env.Console.WriteLine("Looking for available updates"); // Check for dnvm updates - if (await CheckForSelfUpdates(env.HttpClient, env.Console, logger, releasesUrl, manifest.PreviewsEnabled) is (true, _)) + var configFile = new DnvmConfigFile(); + var config = configFile.Read(); + if (await CheckForSelfUpdates(env.HttpClient, env.Console, logger, releasesUrl, config.PreviewsEnabled) is (true, _)) { env.Console.WriteLine("dnvm is out of date. Run 'dnvm update --self' to update dnvm."); } @@ -243,8 +245,10 @@ public async Task UpdateSelf(Manifest manifest) return Result.NotASingleFile; } + var configFile = new DnvmConfigFile(); + var config = configFile.Read(); DnvmReleases.Release release; - switch (await CheckForSelfUpdates(_env.HttpClient, _env.Console, _logger, _releasesUrl, manifest.PreviewsEnabled)) + switch (await CheckForSelfUpdates(_env.HttpClient, _env.Console, _logger, _releasesUrl, config.PreviewsEnabled)) { case (false, null): return Result.SelfUpdateFailed; diff --git a/test/IntegrationTests/Runner.cs b/test/IntegrationTests/Runner.cs index fd30916..77f1856 100644 --- a/test/IntegrationTests/Runner.cs +++ b/test/IntegrationTests/Runner.cs @@ -21,14 +21,20 @@ internal static class DnvmRunner } try { + var envVars = new Dictionary + { + ["HOME"] = env.UserHome, + ["DNVM_HOME"] = env.RealPath(UPath.Root) + }; + // Use the test config directory if it's set + if (DnvmConfigFile.TestConfigDirectory is not null) + { + envVars["DNVM_TEST_CONFIG_DIR"] = DnvmConfigFile.TestConfigDirectory; + } var procResult = await ProcUtil.RunWithOutput( dnvmPath, dnvmArgs, - new() - { - ["HOME"] = env.UserHome, - ["DNVM_HOME"] = env.RealPath(UPath.Root) - } + envVars ); // Allow the test to check the environment variables before they are restored envChecker?.Invoke(); diff --git a/test/IntegrationTests/SelfInstallTests.cs b/test/IntegrationTests/SelfInstallTests.cs index fc72950..117108a 100644 --- a/test/IntegrationTests/SelfInstallTests.cs +++ b/test/IntegrationTests/SelfInstallTests.cs @@ -305,23 +305,18 @@ public Task UpdateSelfPreview() => RunWithServer(async (mockServer, env) => Assert.True(timeAfterUpdate == timeBeforeUpdate); Assert.Contains("Dnvm is up-to-date", result.Out); - result = await ProcUtil.RunWithOutput( - copiedExe, - $"--enable-dnvm-previews", - new() { - ["HOME"] = env.UserHome, - ["DNVM_HOME"] = env.RealPath(UPath.Root) - } - ); - Assert.Equal(0, result.ExitCode); + // Manually create config file with previews enabled + var config = new DnvmConfig { PreviewsEnabled = true }; + var configFile = new DnvmConfigFile(); + configFile.Write(config); result = await DnvmRunner.RunAndRestoreEnv( env, copiedExe, $"update --self --dnvm-url {mockServer.DnvmReleasesUrl} -v" ); - timeAfterUpdate = File.GetLastWriteTimeUtc(copiedExe); - Assert.True(timeAfterUpdate > timeBeforeUpdate); + var timeAfterPreviewUpdate = File.GetLastWriteTimeUtc(copiedExe); + Assert.True(timeAfterPreviewUpdate > timeBeforeUpdate); Assert.Contains("Process successfully upgraded", result.Out); }); diff --git a/test/IntegrationTests/UpdateTests.cs b/test/IntegrationTests/UpdateTests.cs index d29ff25..6cb3d69 100644 --- a/test/IntegrationTests/UpdateTests.cs +++ b/test/IntegrationTests/UpdateTests.cs @@ -51,8 +51,10 @@ public async Task EnablePreviewsAndDownload() => await TestWithServer(async (moc Assert.Contains("dnvm is up-to-date", output, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, proc.ExitCode); - proc = await DnvmRunner.RunAndRestoreEnv(env, SelfInstallTests.DnvmExe, "--enable-dnvm-previews"); - Assert.Equal(0, proc.ExitCode); + // Manually create config file with previews enabled + var config = new DnvmConfig { PreviewsEnabled = true }; + var configFile = new DnvmConfigFile(); + configFile.Write(config); proc = await DnvmRunner.RunAndRestoreEnv(env, SelfInstallTests.DnvmExe, $"update --self -v --dnvm-url {mockServer.DnvmReleasesUrl}"); diff --git a/test/Shared/TestOptions.cs b/test/Shared/TestOptions.cs index 969fff1..9f9a3be 100644 --- a/test/Shared/TestOptions.cs +++ b/test/Shared/TestOptions.cs @@ -1,4 +1,5 @@ +using System.IO; using Spectre.Console.Testing; using Zio; using Zio.FileSystems; @@ -10,9 +11,11 @@ public sealed class TestEnv : IDisposable private readonly TempDirectory _userHome = TestUtils.CreateTempDirectory(); private readonly TempDirectory _dnvmHome = TestUtils.CreateTempDirectory(); private readonly TempDirectory _workingDir = TestUtils.CreateTempDirectory(); + private readonly TempDirectory _configDir = TestUtils.CreateTempDirectory(); private readonly Dictionary _envVars = new(); public DnvmEnv DnvmEnv { get; init; } + public string ConfigDirPath => _configDir.Path; public TestEnv( string dotnetFeedUrl, @@ -26,6 +29,9 @@ public TestEnv( var cwdFs = new SubFileSystem(physicalFs, physicalFs.ConvertPathFromInternal(_workingDir.Path)); cwdFs.CreateDirectory(cwd); + // Set the test config directory + DnvmConfigFile.TestConfigDirectory = _configDir.Path; + DnvmEnv = new DnvmEnv( userHome: _userHome.Path, dnvmFs, @@ -43,6 +49,9 @@ public void Dispose() { _userHome.Dispose(); _dnvmHome.Dispose(); + _workingDir.Dispose(); + _configDir.Dispose(); + DnvmConfigFile.TestConfigDirectory = null; DnvmEnv.Dispose(); } } \ No newline at end of file diff --git a/test/UnitTests/CommandLineTests.cs b/test/UnitTests/CommandLineTests.cs index 851146b..d775b59 100644 --- a/test/UnitTests/CommandLineTests.cs +++ b/test/UnitTests/CommandLineTests.cs @@ -9,12 +9,11 @@ namespace Dnvm.Test; public sealed class CommandLineTests { private static readonly string ExpectedHelpText = """ - usage: dnvm [--enable-dnvm-previews] [-h | --help] + usage: dnvm [-h | --help] Install and manage .NET SDKs. Options: - --enable-dnvm-previews Enable dnvm previews. -h, --help Show help information. Commands: @@ -177,15 +176,6 @@ public void TrackMixedCase() }); } - [Fact] - public void EnableDnvmPreviews() - { - var options = CommandLineArguments.ParseRaw(new TestConsole(), [ - "--enable-dnvm-previews", - ]); - Assert.True(options!.EnableDnvmPreviews); - } - [Theory] [InlineData("-h")] [InlineData("--help")] diff --git a/test/UnitTests/ManifestTests.cs b/test/UnitTests/ManifestTests.cs index 69607a6..b50dfa5 100644 --- a/test/UnitTests/ManifestTests.cs +++ b/test/UnitTests/ManifestTests.cs @@ -136,8 +136,7 @@ public void WriteManifestV9() InstalledSdkVersions = [ SemVersion.Parse("8.0.100-preview.3.23178.7", SemVersionStyles.Strict) ] } ], - CurrentSdkDir = new SdkDirName("dn"), - PreviewsEnabled = false + CurrentSdkDir = new SdkDirName("dn") }; var expected = """ {