From 4c8b6cdd9f8ef004aa49c0523f643a18adad3713 Mon Sep 17 00:00:00 2001 From: Canon Date: Wed, 4 Feb 2026 08:58:36 +0800 Subject: [PATCH] Version 1.0 --- .gitignore | 481 ++++++++++++++++ .idea/.idea.FCPModUpdater/.idea/.gitignore | 13 + .idea/.idea.FCPModUpdater/.idea/encodings.xml | 4 + .../.idea.FCPModUpdater/.idea/indexLayout.xml | 8 + .idea/.idea.FCPModUpdater/.idea/vcs.xml | 6 + Commands/ScanCommand.cs | 93 +++ Commands/Settings/ModPathSettings.cs | 24 + Commands/UpdateCommand.cs | 128 +++++ FCPModUpdater.csproj | 17 + FCPModUpdater.slnx | 3 + Models/GitCommitInfo.cs | 9 + Models/InstalledMod.cs | 17 + Models/ModStatus.cs | 20 + Models/RemoteRepo.cs | 12 + Program.cs | 22 + Services/GitHubApiService.cs | 123 ++++ Services/GitService.cs | 264 +++++++++ Services/IGitHubApiService.cs | 11 + Services/IGitService.cs | 21 + Services/IModDiscoveryService.cs | 11 + Services/ModDiscoveryService.cs | 217 +++++++ Services/PathDiscoveryService.cs | 91 +++ UI/InteractiveMenu.cs | 537 ++++++++++++++++++ UI/ModTableRenderer.cs | 174 ++++++ UI/ProgressReporter.cs | 101 ++++ 25 files changed, 2407 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.idea.FCPModUpdater/.idea/.gitignore create mode 100644 .idea/.idea.FCPModUpdater/.idea/encodings.xml create mode 100644 .idea/.idea.FCPModUpdater/.idea/indexLayout.xml create mode 100644 .idea/.idea.FCPModUpdater/.idea/vcs.xml create mode 100644 Commands/ScanCommand.cs create mode 100644 Commands/Settings/ModPathSettings.cs create mode 100644 Commands/UpdateCommand.cs create mode 100644 FCPModUpdater.csproj create mode 100644 FCPModUpdater.slnx create mode 100644 Models/GitCommitInfo.cs create mode 100644 Models/InstalledMod.cs create mode 100644 Models/ModStatus.cs create mode 100644 Models/RemoteRepo.cs create mode 100644 Program.cs create mode 100644 Services/GitHubApiService.cs create mode 100644 Services/GitService.cs create mode 100644 Services/IGitHubApiService.cs create mode 100644 Services/IGitService.cs create mode 100644 Services/IModDiscoveryService.cs create mode 100644 Services/ModDiscoveryService.cs create mode 100644 Services/PathDiscoveryService.cs create mode 100644 UI/InteractiveMenu.cs create mode 100644 UI/ModTableRenderer.cs create mode 100644 UI/ProgressReporter.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e56cfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,481 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rider,csharp +# Edit at https://www.toptal.com/developers/gitignore?templates=rider,csharp + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# End of https://www.toptal.com/developers/gitignore/api/rider,csharp \ No newline at end of file diff --git a/.idea/.idea.FCPModUpdater/.idea/.gitignore b/.idea/.idea.FCPModUpdater/.idea/.gitignore new file mode 100644 index 0000000..974484e --- /dev/null +++ b/.idea/.idea.FCPModUpdater/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/modules.xml +/.idea.FCPModUpdater.iml +/contentModel.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.FCPModUpdater/.idea/encodings.xml b/.idea/.idea.FCPModUpdater/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.FCPModUpdater/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.FCPModUpdater/.idea/indexLayout.xml b/.idea/.idea.FCPModUpdater/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.FCPModUpdater/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.FCPModUpdater/.idea/vcs.xml b/.idea/.idea.FCPModUpdater/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.FCPModUpdater/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Commands/ScanCommand.cs b/Commands/ScanCommand.cs new file mode 100644 index 0000000..bfb4bde --- /dev/null +++ b/Commands/ScanCommand.cs @@ -0,0 +1,93 @@ +using FCPModUpdater.Commands.Settings; +using FCPModUpdater.Services; +using FCPModUpdater.UI; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FCPModUpdater.Commands; + +public class ScanCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, ModPathSettings settings, + CancellationToken cancellationToken) + { + try + { + // Resolve mods directory + var modsDirectory = ResolveModsDirectory(settings); + if (modsDirectory == null) + { + return 1; + } + + AnsiConsole.MarkupLine($"[grey]Using mods directory: {modsDirectory}[/]"); + AnsiConsole.WriteLine(); + + // Check for git + var gitService = new GitService(); + if (!await gitService.IsGitInstalledAsync(cancellationToken)) + { + AnsiConsole.MarkupLine("[yellow]Warning: Git is not installed or not in PATH.[/]"); + AnsiConsole.MarkupLine("[yellow]Git-based features will be limited.[/]"); + AnsiConsole.MarkupLine("[grey]Install git from: https://git-scm.com/downloads[/]"); + AnsiConsole.WriteLine(); + } + + // Initialize services + var gitHubApiService = new GitHubApiService(); + var modDiscoveryService = new ModDiscoveryService(gitService, gitHubApiService); + + // Run interactive menu + var menu = new InteractiveMenu( + gitService, + gitHubApiService, + modDiscoveryService, + modsDirectory); + + await menu.RunAsync(cancellationToken); + + return 0; + } + catch (OperationCanceledException) + { + AnsiConsole.MarkupLine("[grey]Operation cancelled.[/]"); + return 1; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + return 1; + } + } + + private static string? ResolveModsDirectory(ModPathSettings settings) + { + if (settings.ModDirectory != null) + { + return settings.ModDirectory.FullName; + } + + // Auto-discover + var pathDiscovery = new PathDiscoveryService(); + var paths = pathDiscovery.DiscoverModPaths(); + + if (paths.Count == 0) + { + AnsiConsole.MarkupLine("[red]Error: Could not find RimWorld Mods folder.[/]"); + AnsiConsole.MarkupLine("[grey]Please specify the path using --directory[/]"); + return null; + } + + if (paths.Count == 1) + { + return paths[0]; + } + + // Multiple paths found - let user choose + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[bold]Multiple RimWorld installations found. Select one:[/]") + .PageSize(10) + .AddChoices(paths)); + } +} diff --git a/Commands/Settings/ModPathSettings.cs b/Commands/Settings/ModPathSettings.cs new file mode 100644 index 0000000..84b7a0d --- /dev/null +++ b/Commands/Settings/ModPathSettings.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using JetBrains.Annotations; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FCPModUpdater.Commands.Settings; + +[UsedImplicitly] +public class ModPathSettings : CommandSettings +{ + [CommandOption("-d|--directory")] + [Description("Path to the RimWorld/Mods folder. If not specified, will attempt to auto-discover.")] + public DirectoryInfo? ModDirectory { get; init; } + + public override ValidationResult Validate() + { + if (ModDirectory != null && !ModDirectory.Exists) + { + return ValidationResult.Error($"Directory does not exist: {ModDirectory.FullName}"); + } + + return ValidationResult.Success(); + } +} diff --git a/Commands/UpdateCommand.cs b/Commands/UpdateCommand.cs new file mode 100644 index 0000000..36e907a --- /dev/null +++ b/Commands/UpdateCommand.cs @@ -0,0 +1,128 @@ +using FCPModUpdater.Commands.Settings; +using FCPModUpdater.Models; +using FCPModUpdater.Services; +using FCPModUpdater.UI; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace FCPModUpdater.Commands; + +public class UpdateCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, ModPathSettings settings, + CancellationToken cancellationToken) + { + try + { + // Resolve mods directory + var modsDirectory = ResolveModsDirectory(settings); + if (modsDirectory == null) + { + return 1; + } + + AnsiConsole.MarkupLine($"[grey]Using mods directory: {modsDirectory}[/]"); + AnsiConsole.WriteLine(); + + // Check for git + var gitService = new GitService(); + if (!await gitService.IsGitInstalledAsync(cancellationToken)) + { + AnsiConsole.MarkupLine("[red]Error: Git is not installed or not in PATH.[/]"); + AnsiConsole.MarkupLine("[grey]Install git from: https://git-scm.com/downloads[/]"); + return 1; + } + + // Initialize services + var gitHubApiService = new GitHubApiService(); + var modDiscoveryService = new ModDiscoveryService(gitService, gitHubApiService); + + // Discover mods + var mods = await ProgressReporter.WithStatusAsync( + "Scanning mods directory...", + async () => await modDiscoveryService.DiscoverModsAsync(modsDirectory, ct: cancellationToken)); + + // Find updateable mods + var updateableMods = mods + .Where(m => m.Source == ModSource.Git && m.Status == ModStatus.Behind) + .ToList(); + + if (updateableMods.Count == 0) + { + AnsiConsole.MarkupLine("[green]All FCP mods are up to date![/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[yellow]Found {updateableMods.Count} mod(s) with updates available:[/]"); + foreach (var mod in updateableMods) + { + AnsiConsole.MarkupLine($" • {mod.Name} [grey]({mod.CommitsBehind} commits behind)[/]"); + } + + AnsiConsole.WriteLine(); + + // Update all + var results = await ProgressReporter.WithBatchProgressAsync( + "Updating mods", + updateableMods, + m => m.Name, + async (mod, progress) => + { + progress.Report(25); + var fetchOk = await gitService.FetchAsync(mod.Path, ct: cancellationToken); + if (!fetchOk) + return (false, "Fetch failed"); + + progress.Report(50); + var pullOk = await gitService.PullAsync(mod.Path, ct: cancellationToken); + progress.Report(100); + + return (pullOk, pullOk ? null : "Pull failed"); + }); + + ModTableRenderer.RenderUpdateSummary(results); + + var failCount = results.Count(r => !r.Success); + return failCount > 0 ? 1 : 0; + } + catch (OperationCanceledException) + { + AnsiConsole.MarkupLine("[grey]Operation cancelled.[/]"); + return 1; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + return 1; + } + } + + private static string? ResolveModsDirectory(ModPathSettings settings) + { + if (settings.ModDirectory != null) + { + return settings.ModDirectory.FullName; + } + + // Auto-discover + var pathDiscovery = new PathDiscoveryService(); + var paths = pathDiscovery.DiscoverModPaths(); + + if (paths.Count == 0) + { + AnsiConsole.MarkupLine("[red]Error: Could not find RimWorld Mods folder.[/]"); + AnsiConsole.MarkupLine("[grey]Please specify the path using --directory[/]"); + return null; + } + + if (paths.Count == 1) + { + return paths[0]; + } + + // Multiple paths found - use first one for non-interactive mode + AnsiConsole.MarkupLine($"[yellow]Multiple installations found, using: {paths[0]}[/]"); + AnsiConsole.MarkupLine("[grey]Use --directory to specify a different path[/]"); + return paths[0]; + } +} diff --git a/FCPModUpdater.csproj b/FCPModUpdater.csproj new file mode 100644 index 0000000..d474edb --- /dev/null +++ b/FCPModUpdater.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + + + diff --git a/FCPModUpdater.slnx b/FCPModUpdater.slnx new file mode 100644 index 0000000..7a6a306 --- /dev/null +++ b/FCPModUpdater.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Models/GitCommitInfo.cs b/Models/GitCommitInfo.cs new file mode 100644 index 0000000..2130e7c --- /dev/null +++ b/Models/GitCommitInfo.cs @@ -0,0 +1,9 @@ +namespace FCPModUpdater.Models; + +public record GitCommitInfo( + string Hash, + string ShortHash, + string Message, + string Author, + DateTimeOffset Date +); diff --git a/Models/InstalledMod.cs b/Models/InstalledMod.cs new file mode 100644 index 0000000..f111ebd --- /dev/null +++ b/Models/InstalledMod.cs @@ -0,0 +1,17 @@ +namespace FCPModUpdater.Models; + +public record InstalledMod +{ + public required string Name { get; init; } + public required string Path { get; init; } + public required ModSource Source { get; init; } + public string? RemoteUrl { get; init; } + public string? Branch { get; init; } + public GitCommitInfo? CurrentCommit { get; init; } + public ModStatus Status { get; init; } + public int CommitsBehind { get; init; } + public int CommitsAhead { get; init; } + public string? MatchedRepoName { get; init; } + public string? ErrorMessage { get; init; } + public bool HasLocalChanges { get; init; } +} diff --git a/Models/ModStatus.cs b/Models/ModStatus.cs new file mode 100644 index 0000000..fd1cc54 --- /dev/null +++ b/Models/ModStatus.cs @@ -0,0 +1,20 @@ +namespace FCPModUpdater.Models; + +public enum ModStatus +{ + UpToDate, + Behind, + Ahead, + Diverged, + LocalChanges, + NonGit, + Error, + Unknown +} + +public enum ModSource +{ + Git, + Local, + Workshop +} diff --git a/Models/RemoteRepo.cs b/Models/RemoteRepo.cs new file mode 100644 index 0000000..144d030 --- /dev/null +++ b/Models/RemoteRepo.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace FCPModUpdater.Models; + +public record RemoteRepo( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("clone_url")] string CloneUrl, + [property: JsonPropertyName("ssh_url")] string SshUrl, + [property: JsonPropertyName("default_branch")] string DefaultBranch, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("html_url")] string HtmlUrl +); diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..c56eddf --- /dev/null +++ b/Program.cs @@ -0,0 +1,22 @@ +using FCPModUpdater.Commands; +using Spectre.Console.Cli; + +var app = new CommandApp(); + +app.Configure(config => +{ + config.SetApplicationName("fcp-mod-manager"); + config.SetApplicationVersion("1.0.0"); + + config.AddCommand("scan") + .WithDescription("Scan mods and show interactive menu") + .WithExample("scan") + .WithExample("scan", "--directory", "/path/to/RimWorld/Mods"); + + config.AddCommand("update") + .WithDescription("Update all FCP mods (non-interactive)") + .WithExample("update") + .WithExample("update", "--directory", "/path/to/RimWorld/Mods"); +}); + +return await app.RunAsync(args); diff --git a/Services/GitHubApiService.cs b/Services/GitHubApiService.cs new file mode 100644 index 0000000..c62e569 --- /dev/null +++ b/Services/GitHubApiService.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Json; +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public class GitHubApiService : IGitHubApiService, IDisposable +{ + private const string OrgName = "FalloutCollaborationProject"; + private const string BaseUrl = "https://api.github.com"; + + private readonly HttpClient _httpClient; + private readonly TimeSpan _cacheExpiry = TimeSpan.FromHours(1); + + private IReadOnlyList? _cachedRepos; + private DateTimeOffset _cacheTime = DateTimeOffset.MinValue; + + public int? RemainingRateLimit { get; private set; } + public DateTimeOffset? RateLimitReset { get; private set; } + + public GitHubApiService(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? new HttpClient(); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("FCPModUpdater/1.0"); + _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + + var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (!string.IsNullOrEmpty(token)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + } + + public async Task> GetOrganizationReposAsync(CancellationToken ct = default) + { + if (_cachedRepos != null && DateTimeOffset.UtcNow - _cacheTime < _cacheExpiry) + { + return _cachedRepos; + } + + var allRepos = new List(); + var page = 1; + const int perPage = 100; + + while (true) + { + var url = $"{BaseUrl}/orgs/{OrgName}/repos?per_page={perPage}&page={page}"; + + try + { + HttpResponseMessage response = await _httpClient.GetAsync(url, ct); + UpdateRateLimitInfo(response); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden && RemainingRateLimit == 0) + { + // Rate limited - return cached data if available + if (_cachedRepos != null) + return _cachedRepos; + } + + break; + } + + var repos = await response.Content.ReadFromJsonAsync>(ct); + if (repos == null || repos.Count == 0) + break; + + allRepos.AddRange(repos); + + if (repos.Count < perPage) + break; + + page++; + } + catch (HttpRequestException) + { + // Network error - return cached data if available + if (_cachedRepos != null) + return _cachedRepos; + + break; + } + } + + if (allRepos.Count > 0) + { + _cachedRepos = allRepos; + _cacheTime = DateTimeOffset.UtcNow; + } + + return _cachedRepos ?? []; + } + + public async Task GetRepoByNameAsync(string repoName, CancellationToken ct = default) + { + var repos = await GetOrganizationReposAsync(ct); + return repos.FirstOrDefault(r => + r.Name.Equals(repoName, StringComparison.OrdinalIgnoreCase)); + } + + private void UpdateRateLimitInfo(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining)) + { + if (int.TryParse(remaining.FirstOrDefault(), out var value)) + RemainingRateLimit = value; + } + + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var reset)) + { + if (long.TryParse(reset.FirstOrDefault(), out var timestamp)) + RateLimitReset = DateTimeOffset.FromUnixTimeSeconds(timestamp); + } + } + + public void Dispose() + { + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Services/GitService.cs b/Services/GitService.cs new file mode 100644 index 0000000..0709aed --- /dev/null +++ b/Services/GitService.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.Text; +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public class GitService : IGitService +{ + private bool? _gitInstalled; + + public async Task IsGitInstalledAsync(CancellationToken ct = default) + { + if (_gitInstalled.HasValue) + return _gitInstalled.Value; + + var (exitCode, _, _) = await RunGitCommandAsync(".", "--version", ct); + _gitInstalled = exitCode == 0; + return _gitInstalled.Value; + } + + public async Task IsGitRepositoryAsync(string path, CancellationToken ct = default) + { + var (exitCode, _, _) = await RunGitCommandAsync(path, "rev-parse --is-inside-work-tree", ct); + return exitCode == 0; + } + + public async Task GetRemoteUrlAsync(string path, CancellationToken ct = default) + { + var (exitCode, output, _) = await RunGitCommandAsync(path, "remote get-url origin", ct); + return exitCode == 0 ? output.Trim() : null; + } + + public async Task GetCurrentCommitAsync(string path, CancellationToken ct = default) + { + var format = "%H%n%h%n%s%n%an%n%aI"; + var (exitCode, output, _) = await RunGitCommandAsync(path, $"log -1 --format=\"{format}\"", ct); + + if (exitCode != 0) + return null; + + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 5) + return null; + + return new GitCommitInfo( + Hash: lines[0].Trim(), + ShortHash: lines[1].Trim(), + Message: lines[2].Trim(), + Author: lines[3].Trim(), + Date: DateTimeOffset.Parse(lines[4].Trim()) + ); + } + + public async Task GetCurrentBranchAsync(string path, CancellationToken ct = default) + { + var (exitCode, output, _) = await RunGitCommandAsync(path, "rev-parse --abbrev-ref HEAD", ct); + if (exitCode != 0) + return null; + + var branch = output.Trim(); + return branch == "HEAD" ? null : branch; // Detached HEAD + } + + public async Task<(int Behind, int Ahead)> GetCommitDifferenceAsync(string path, CancellationToken ct = default) + { + var branch = await GetCurrentBranchAsync(path, ct); + if (branch == null) + return (0, 0); + + var (exitCode, output, _) = await RunGitCommandAsync(path, + $"rev-list --left-right --count origin/{branch}...HEAD", ct); + + if (exitCode != 0) + return (0, 0); + + var parts = output.Trim().Split('\t'); + if (parts.Length != 2) + return (0, 0); + + int.TryParse(parts[0], out var behind); + int.TryParse(parts[1], out var ahead); + + return (behind, ahead); + } + + public async Task> GetIncomingCommitsAsync(string path, int limit = 10, + CancellationToken ct = default) + { + var branch = await GetCurrentBranchAsync(path, ct); + if (branch == null) + return []; + + var format = "%H%n%h%n%s%n%an%n%aI%n---"; + var (exitCode, output, _) = await RunGitCommandAsync(path, + $"log HEAD..origin/{branch} --format=\"{format}\" -n {limit}", ct); + + if (exitCode != 0) + return []; + + return ParseCommitLog(output); + } + + public async Task> GetCommitHistoryAsync(string path, int limit = 20, + CancellationToken ct = default) + { + var format = "%H%n%h%n%s%n%an%n%aI%n---"; + var (exitCode, output, _) = await RunGitCommandAsync(path, + $"log --format=\"{format}\" -n {limit}", ct); + + if (exitCode != 0) + return []; + + return ParseCommitLog(output); + } + + public async Task FetchAsync(string path, IProgress? progress = null, + CancellationToken ct = default) + { + progress?.Report("Fetching from remote..."); + var (exitCode, _, error) = await RunGitCommandAsync(path, "fetch --all --prune", ct); + + if (exitCode != 0) + progress?.Report($"Fetch failed: {error}"); + + return exitCode == 0; + } + + public async Task PullAsync(string path, IProgress? progress = null, + CancellationToken ct = default) + { + progress?.Report("Pulling changes..."); + var (exitCode, _, error) = await RunGitCommandAsync(path, "pull --ff-only", ct); + + if (exitCode != 0) + progress?.Report($"Pull failed: {error}"); + + return exitCode == 0; + } + + public async Task CloneAsync(string url, string targetPath, IProgress? progress = null, + CancellationToken ct = default) + { + progress?.Report($"Cloning {url}..."); + + var parentDir = Path.GetDirectoryName(targetPath) ?? "."; + var folderName = Path.GetFileName(targetPath); + + var (exitCode, _, error) = await RunGitCommandAsync(parentDir, $"clone \"{url}\" \"{folderName}\"", ct, + timeoutMs: 300000); // 5 minute timeout for clone + + if (exitCode != 0) + progress?.Report($"Clone failed: {error}"); + + return exitCode == 0; + } + + public async Task> GetRemoteBranchesAsync(string path, CancellationToken ct = default) + { + var (exitCode, output, _) = await RunGitCommandAsync(path, "branch -r", ct); + + if (exitCode != 0) + return []; + + return output.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(b => b.Trim()) + .Where(b => !b.Contains("->")) // Filter out HEAD -> origin/main + .Select(b => b.StartsWith("origin/") ? b[7..] : b) + .ToList(); + } + + public async Task CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default) + { + var (exitCode, _, _) = await RunGitCommandAsync(path, $"checkout \"{branchOrCommit}\"", ct); + return exitCode == 0; + } + + public async Task HasLocalChangesAsync(string path, CancellationToken ct = default) + { + var (exitCode, output, _) = await RunGitCommandAsync(path, "status --porcelain", ct); + return exitCode == 0 && !string.IsNullOrWhiteSpace(output); + } + + private static IReadOnlyList ParseCommitLog(string output) + { + var commits = new List(); + var entries = output.Split("---", StringSplitOptions.RemoveEmptyEntries); + + foreach (var entry in entries) + { + var lines = entry.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 5) + continue; + + commits.Add(new GitCommitInfo( + Hash: lines[0].Trim(), + ShortHash: lines[1].Trim(), + Message: lines[2].Trim(), + Author: lines[3].Trim(), + Date: DateTimeOffset.TryParse(lines[4].Trim(), out var date) ? date : DateTimeOffset.MinValue + )); + } + + return commits; + } + + private static async Task<(int ExitCode, string Output, string Error)> RunGitCommandAsync( + string workingDirectory, string arguments, CancellationToken ct, int timeoutMs = 30000) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) outputBuilder.AppendLine(e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) errorBuilder.AppendLine(e.Data); + }; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeoutMs); + + await process.WaitForExitAsync(cts.Token); + + return (process.ExitCode, outputBuilder.ToString(), errorBuilder.ToString()); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Ignore kill errors + } + + return (ExitCode: -1, Output: "", Error: "Operation timed out or was cancelled"); + } + catch (Exception ex) + { + return (ExitCode: -1, Output: "", Error: ex.Message); + } + } +} diff --git a/Services/IGitHubApiService.cs b/Services/IGitHubApiService.cs new file mode 100644 index 0000000..9512e2f --- /dev/null +++ b/Services/IGitHubApiService.cs @@ -0,0 +1,11 @@ +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public interface IGitHubApiService +{ + Task> GetOrganizationReposAsync(CancellationToken ct = default); + Task GetRepoByNameAsync(string repoName, CancellationToken ct = default); + int? RemainingRateLimit { get; } + DateTimeOffset? RateLimitReset { get; } +} diff --git a/Services/IGitService.cs b/Services/IGitService.cs new file mode 100644 index 0000000..9aef2a6 --- /dev/null +++ b/Services/IGitService.cs @@ -0,0 +1,21 @@ +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public interface IGitService +{ + Task IsGitInstalledAsync(CancellationToken ct = default); + Task IsGitRepositoryAsync(string path, CancellationToken ct = default); + Task GetRemoteUrlAsync(string path, CancellationToken ct = default); + Task GetCurrentCommitAsync(string path, CancellationToken ct = default); + Task GetCurrentBranchAsync(string path, CancellationToken ct = default); + Task<(int Behind, int Ahead)> GetCommitDifferenceAsync(string path, CancellationToken ct = default); + Task> GetIncomingCommitsAsync(string path, int limit = 10, CancellationToken ct = default); + Task FetchAsync(string path, IProgress? progress = null, CancellationToken ct = default); + Task PullAsync(string path, IProgress? progress = null, CancellationToken ct = default); + Task CloneAsync(string url, string targetPath, IProgress? progress = null, CancellationToken ct = default); + Task> GetRemoteBranchesAsync(string path, CancellationToken ct = default); + Task CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default); + Task HasLocalChangesAsync(string path, CancellationToken ct = default); + Task> GetCommitHistoryAsync(string path, int limit = 20, CancellationToken ct = default); +} diff --git a/Services/IModDiscoveryService.cs b/Services/IModDiscoveryService.cs new file mode 100644 index 0000000..90689b7 --- /dev/null +++ b/Services/IModDiscoveryService.cs @@ -0,0 +1,11 @@ +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public interface IModDiscoveryService +{ + Task> DiscoverModsAsync( + string modsDirectory, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/Services/ModDiscoveryService.cs b/Services/ModDiscoveryService.cs new file mode 100644 index 0000000..773c20f --- /dev/null +++ b/Services/ModDiscoveryService.cs @@ -0,0 +1,217 @@ +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public class ModDiscoveryService : IModDiscoveryService +{ + private const string FcpOrgUrl = "github.com/FalloutCollaborationProject/"; + private const string FcpOrgUrlAlt = "github.com:FalloutCollaborationProject/"; // SSH format + + private readonly IGitService _gitService; + private readonly IGitHubApiService _gitHubApiService; + + public ModDiscoveryService(IGitService gitService, IGitHubApiService gitHubApiService) + { + _gitService = gitService; + _gitHubApiService = gitHubApiService; + } + + public async Task> DiscoverModsAsync( + string modsDirectory, + IProgress? progress = null, + CancellationToken ct = default) + { + if (!Directory.Exists(modsDirectory)) + { + return []; + } + + var gitInstalled = await _gitService.IsGitInstalledAsync(ct); + var orgRepos = await _gitHubApiService.GetOrganizationReposAsync(ct); + var orgRepoNames = orgRepos.Select(r => r.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var directories = Directory.GetDirectories(modsDirectory); + var mods = new List(); + + foreach (var dir in directories) + { + ct.ThrowIfCancellationRequested(); + + var folderName = Path.GetFileName(dir); + progress?.Report($"Scanning: {folderName}"); + + InstalledMod? mod = await AnalyzeModDirectoryAsync(dir, folderName, gitInstalled, orgRepoNames, ct); + if (mod != null) + { + mods.Add(mod); + } + } + + return mods.OrderBy(m => m.Name).ToList(); + } + + private async Task AnalyzeModDirectoryAsync( + string path, + string folderName, + bool gitInstalled, + HashSet orgRepoNames, + CancellationToken ct) + { + // Check if it's a Workshop mod (has PublishedFileId.txt) + var isWorkshop = File.Exists(Path.Combine(path, "About", "PublishedFileId.txt")); + + if (!gitInstalled) + { + // Can't check git status without git installed + // Check if folder name matches an org repo + if (orgRepoNames.Contains(folderName)) + { + return new InstalledMod + { + Name = folderName, + Path = path, + Source = isWorkshop ? ModSource.Workshop : ModSource.Local, + Status = ModStatus.NonGit, + MatchedRepoName = folderName + }; + } + + return null; + } + + var isGitRepo = await _gitService.IsGitRepositoryAsync(path, ct); + + if (!isGitRepo) + { + // Not a git repo - check if it matches an org repo by name + if (orgRepoNames.Contains(folderName)) + { + return new InstalledMod + { + Name = folderName, + Path = path, + Source = isWorkshop ? ModSource.Workshop : ModSource.Local, + Status = ModStatus.NonGit, + MatchedRepoName = folderName + }; + } + + return null; + } + + // It's a git repo - check if it's an FCP mod + var remoteUrl = await _gitService.GetRemoteUrlAsync(path, ct); + + if (string.IsNullOrEmpty(remoteUrl)) + { + // Git repo without remote - check if folder matches org repo + if (orgRepoNames.Contains(folderName)) + { + return new InstalledMod + { + Name = folderName, + Path = path, + Source = ModSource.Git, + Status = ModStatus.Error, + ErrorMessage = "Git repository has no remote configured", + MatchedRepoName = folderName + }; + } + + return null; + } + + var isFcpMod = remoteUrl.Contains(FcpOrgUrl, StringComparison.OrdinalIgnoreCase) || + remoteUrl.Contains(FcpOrgUrlAlt, StringComparison.OrdinalIgnoreCase); + + if (!isFcpMod) + { + // Check if folder name matches org repo (might be cloned from fork) + if (!orgRepoNames.Contains(folderName)) + { + return null; + } + } + + // It's an FCP mod - gather full status + return await BuildInstalledModAsync(path, folderName, remoteUrl, ct); + } + + private async Task BuildInstalledModAsync( + string path, + string folderName, + string remoteUrl, + CancellationToken ct) + { + try + { + string? branch = await _gitService.GetCurrentBranchAsync(path, ct); + GitCommitInfo? currentCommit = await _gitService.GetCurrentCommitAsync(path, ct); + bool hasLocalChanges = await _gitService.HasLocalChangesAsync(path, ct); + + // Fetch to get accurate behind/ahead counts + await _gitService.FetchAsync(path, ct: ct); + + (int behind, int ahead) = await _gitService.GetCommitDifferenceAsync(path, ct); + + ModStatus status = DetermineStatus(behind, ahead, hasLocalChanges, branch); + + return new InstalledMod + { + Name = folderName, + Path = path, + Source = ModSource.Git, + RemoteUrl = remoteUrl, + Branch = branch, + CurrentCommit = currentCommit, + Status = status, + CommitsBehind = behind, + CommitsAhead = ahead, + HasLocalChanges = hasLocalChanges + }; + } + catch (Exception ex) + { + return new InstalledMod + { + Name = folderName, + Path = path, + Source = ModSource.Git, + RemoteUrl = remoteUrl, + Status = ModStatus.Error, + ErrorMessage = ex.Message + }; + } + } + + private static ModStatus DetermineStatus(int behind, int ahead, bool hasLocalChanges, string? branch) + { + if (branch == null) + { + // Detached HEAD + return ModStatus.Unknown; + } + + if (hasLocalChanges) + { + return ModStatus.LocalChanges; + } + + if (behind > 0 && ahead > 0) + { + return ModStatus.Diverged; + } + + if (behind > 0) + { + return ModStatus.Behind; + } + + if (ahead > 0) + { + return ModStatus.Ahead; + } + + return ModStatus.UpToDate; + } +} diff --git a/Services/PathDiscoveryService.cs b/Services/PathDiscoveryService.cs new file mode 100644 index 0000000..9b9f422 --- /dev/null +++ b/Services/PathDiscoveryService.cs @@ -0,0 +1,91 @@ +namespace FCPModUpdater.Services; + +public class PathDiscoveryService +{ + public IReadOnlyList DiscoverModPaths() + { + var paths = new List(); + + if (OperatingSystem.IsLinux()) + { + paths.AddRange(GetLinuxPaths()); + } + else if (OperatingSystem.IsWindows()) + { + paths.AddRange(GetWindowsPaths()); + } + else if (OperatingSystem.IsMacOS()) + { + paths.AddRange(GetMacPaths()); + } + + return paths.Where(Directory.Exists).Distinct().ToList(); + } + + private static List GetLinuxPaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return + [ + // Steam (standard) + Path.Combine(home, ".steam/steam/steamapps/common/RimWorld/Mods"), + Path.Combine(home, ".local/share/Steam/steamapps/common/RimWorld/Mods"), + + // Steam (flatpak) + Path.Combine(home, ".var/app/com.valvesoftware.Steam/.steam/steam/steamapps/common/RimWorld/Mods"), + Path.Combine(home, + ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/RimWorld/Mods"), + + // GOG + Path.Combine(home, "Games/RimWorld/Mods"), + Path.Combine(home, "GOG Games/RimWorld/Mods"), + + // Lutris + Path.Combine(home, "Games/rimworld/drive_c/GOG Games/RimWorld/Mods") + ]; + } + + private static List GetWindowsPaths() + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + var paths = new List + { + // Steam default locations + Path.Combine(programFilesX86, "Steam/steamapps/common/RimWorld/Mods"), + Path.Combine(programFiles, "Steam/steamapps/common/RimWorld/Mods"), + + // GOG default location + Path.Combine(programFilesX86, "GOG Galaxy/Games/RimWorld/Mods"), + Path.Combine(programFiles, "GOG Galaxy/Games/RimWorld/Mods"), + @"C:\GOG Games\RimWorld\Mods", + }; + + // Check common Steam library locations on other drives + foreach (DriveInfo drive in DriveInfo.GetDrives().Where(d => d.DriveType == DriveType.Fixed)) + { + paths.Add(Path.Combine(drive.Name, "SteamLibrary/steamapps/common/RimWorld/Mods")); + paths.Add(Path.Combine(drive.Name, "Steam/steamapps/common/RimWorld/Mods")); + paths.Add(Path.Combine(drive.Name, "Games/RimWorld/Mods")); + } + + return paths; + } + + private static List GetMacPaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return + [ + // Steam + Path.Combine(home, "Library/Application Support/Steam/steamapps/common/RimWorld/RimWorldMac.app/Mods"), + + // GOG + Path.Combine(home, "Applications/RimWorld.app/Mods"), + "/Applications/RimWorld.app/Mods" + ]; + } +} diff --git a/UI/InteractiveMenu.cs b/UI/InteractiveMenu.cs new file mode 100644 index 0000000..b8fc7fc --- /dev/null +++ b/UI/InteractiveMenu.cs @@ -0,0 +1,537 @@ +using FCPModUpdater.Models; +using FCPModUpdater.Services; +using Spectre.Console; + +namespace FCPModUpdater.UI; + +public class InteractiveMenu +{ + private readonly IGitService _gitService; + private readonly IGitHubApiService _gitHubApiService; + private readonly IModDiscoveryService _modDiscoveryService; + private readonly string _modsDirectory; + + private IReadOnlyList _mods = []; + + public InteractiveMenu( + IGitService gitService, + IGitHubApiService gitHubApiService, + IModDiscoveryService modDiscoveryService, + string modsDirectory) + { + _gitService = gitService; + _gitHubApiService = gitHubApiService; + _modDiscoveryService = modDiscoveryService; + _modsDirectory = modsDirectory; + } + + public async Task RunAsync(CancellationToken ct = default) + { + // Initial scan + await RefreshModsAsync(ct); + + while (!ct.IsCancellationRequested) + { + AnsiConsole.Clear(); + ModTableRenderer.RenderModTable(_mods, _gitHubApiService.RemainingRateLimit, + _gitHubApiService.RateLimitReset); + AnsiConsole.WriteLine(); + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[bold]What would you like to do?[/]") + .PageSize(10) + .AddChoices( + "Update Git Mods", + "Install New Mods", + "Uninstall Mods", + "Convert Local to Git", + "Mod Version Selector", + "Refresh Status", + "Exit" + )); + + try + { + var shouldExit = choice switch + { + "Update Git Mods" => await HandleUpdateAsync(ct), + "Install New Mods" => await HandleInstallAsync(ct), + "Uninstall Mods" => await HandleUninstallAsync(ct), + "Convert Local to Git" => await HandleConvertAsync(ct), + "Mod Version Selector" => await HandleVersionSelectorAsync(ct), + "Refresh Status" => await HandleRefreshAsync(ct), + "Exit" => true, + _ => false + }; + + if (shouldExit) + break; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + AnsiConsole.WriteException(ex); + AnsiConsole.MarkupLine("\n[grey]Press any key to continue...[/]"); + Console.ReadKey(true); + } + } + } + + private async Task RefreshModsAsync(CancellationToken ct) + { + _mods = await ProgressReporter.WithStatusAsync( + "Scanning mods directory...", + async () => await _modDiscoveryService.DiscoverModsAsync(_modsDirectory, null, ct)); + } + + private async Task HandleUpdateAsync(CancellationToken ct) + { + var updateableMods = _mods + .Where(m => m.Source == ModSource.Git && m.Status == ModStatus.Behind) + .ToList(); + + if (updateableMods.Count == 0) + { + AnsiConsole.MarkupLine("[green]All mods are up to date![/]"); + WaitForKey(); + return false; + } + + var prompt = new MultiSelectionPrompt() + .Title("[bold]Select mods to update:[/]") + .PageSize(15) + .Required(false) + .UseConverter(m => $"{m.Name} [grey]({m.CommitsBehind} commits behind)[/]"); + + foreach (var mod in updateableMods) + { + prompt.AddChoice(mod).Select(); + } + + var selected = AnsiConsole.Prompt(prompt); + + if (selected.Count == 0) + { + return false; + } + + // Show incoming commits for each selected mod + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold]Incoming commits:[/]"); + AnsiConsole.WriteLine(); + + foreach (var mod in selected) + { + var commits = await _gitService.GetIncomingCommitsAsync(mod.Path, 5, ct); + ModTableRenderer.RenderIncomingCommits(mod, commits); + } + + if (!AnsiConsole.Confirm("Proceed with update?")) + { + return false; + } + + var results = await ProgressReporter.WithBatchProgressAsync( + "Updating mods", + selected.ToList(), + m => m.Name, + async (mod, progress) => + { + progress.Report(25); + var fetchOk = await _gitService.FetchAsync(mod.Path, ct: ct); + if (!fetchOk) + return (false, "Fetch failed"); + + progress.Report(50); + var pullOk = await _gitService.PullAsync(mod.Path, ct: ct); + progress.Report(100); + + return (pullOk, pullOk ? null : "Pull failed"); + }); + + ModTableRenderer.RenderUpdateSummary(results); + WaitForKey(); + + await RefreshModsAsync(ct); + return false; + } + + private async Task HandleInstallAsync(CancellationToken ct) + { + var orgRepos = await ProgressReporter.WithStatusAsync( + "Fetching available mods...", + async () => await _gitHubApiService.GetOrganizationReposAsync(ct)); + + var installedNames = _mods.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var availableRepos = orgRepos + .Where(r => !installedNames.Contains(r.Name)) + .OrderBy(r => r.Name) + .ToList(); + + if (availableRepos.Count == 0) + { + AnsiConsole.MarkupLine("[green]All FCP mods are already installed![/]"); + WaitForKey(); + return false; + } + + var selected = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title("[bold]Select mods to install:[/]") + .PageSize(15) + .Required(false) + .UseConverter(r => string.IsNullOrEmpty(r.Description) + ? r.Name + : $"{r.Name} [grey]— {Truncate(r.Description, 50)}[/]") + .AddChoices(availableRepos)); + + if (selected.Count == 0) + { + return false; + } + + var results = await ProgressReporter.WithBatchProgressAsync( + "Installing mods", + selected.ToList(), + r => r.Name, + async (repo, progress) => + { + var targetPath = Path.Combine(_modsDirectory, repo.Name); + progress.Report(10); + + var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, targetPath, ct: ct); + progress.Report(100); + + return (cloneOk, cloneOk ? null : "Clone failed"); + }); + + ModTableRenderer.RenderUpdateSummary(results); + WaitForKey(); + + await RefreshModsAsync(ct); + return false; + } + + private async Task HandleUninstallAsync(CancellationToken ct) + { + var installedMods = _mods.ToList(); + + if (installedMods.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No FCP mods installed.[/]"); + WaitForKey(); + return false; + } + + var selected = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title("[bold red]Select mods to uninstall:[/]") + .PageSize(15) + .Required(false) + .UseConverter(m => m.Name) + .AddChoices(installedMods)); + + if (selected.Count == 0) + { + return false; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold red]WARNING: This will permanently delete the following mods:[/]"); + foreach (var mod in selected) + { + AnsiConsole.MarkupLine($" [red]• {mod.Name}[/] ({mod.Path})"); + } + + AnsiConsole.WriteLine(); + + if (!AnsiConsole.Confirm("[red]Are you sure you want to delete these mods?[/]", defaultValue: false)) + { + return false; + } + + // Double confirmation + var confirmText = AnsiConsole.Ask("Type [red]DELETE[/] to confirm:"); + if (confirmText != "DELETE") + { + AnsiConsole.MarkupLine("[grey]Uninstall cancelled.[/]"); + WaitForKey(); + return false; + } + + var results = new List<(string Name, bool Success, string? Error)>(); + + foreach (var mod in selected) + { + try + { + Directory.Delete(mod.Path, recursive: true); + results.Add((mod.Name, true, null)); + } + catch (Exception ex) + { + results.Add((mod.Name, false, ex.Message)); + } + } + + ModTableRenderer.RenderUpdateSummary(results); + WaitForKey(); + + await RefreshModsAsync(ct); + return false; + } + + private async Task HandleConvertAsync(CancellationToken ct) + { + var nonGitMods = _mods + .Where(m => m.Source != ModSource.Git && !string.IsNullOrEmpty(m.MatchedRepoName)) + .ToList(); + + if (nonGitMods.Count == 0) + { + AnsiConsole.MarkupLine("[grey]No local mods found that match FCP repositories.[/]"); + WaitForKey(); + return false; + } + + var selected = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title("[bold]Select mods to convert to Git:[/]") + .PageSize(15) + .Required(false) + .UseConverter(m => $"{m.Name} [grey]→ will clone from {m.MatchedRepoName}[/]") + .AddChoices(nonGitMods)); + + if (selected.Count == 0) + { + return false; + } + + AnsiConsole.MarkupLine("[yellow]Warning: This will replace local folders with fresh git clones.[/]"); + AnsiConsole.MarkupLine("[yellow]Any local modifications (except About.xml changes) will be lost.[/]"); + + if (!AnsiConsole.Confirm("Proceed with conversion?")) + { + return false; + } + + var results = new List<(string Name, bool Success, string? Error)>(); + + foreach (var mod in selected) + { + var repo = await _gitHubApiService.GetRepoByNameAsync(mod.MatchedRepoName!, ct); + if (repo == null) + { + results.Add((mod.Name, false, "Repository not found")); + continue; + } + + // Backup About.xml if it exists and has modifications + var aboutPath = Path.Combine(mod.Path, "About", "About.xml"); + string? aboutBackup = null; + if (File.Exists(aboutPath)) + { + aboutBackup = await File.ReadAllTextAsync(aboutPath, ct); + } + + try + { + // Delete existing folder + Directory.Delete(mod.Path, recursive: true); + + // Clone fresh + var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, mod.Path, ct: ct); + + if (!cloneOk) + { + results.Add((mod.Name, false, "Clone failed")); + continue; + } + + // Note: We don't restore About.xml as it would create local modifications + // The user should use the game's mod settings instead + + results.Add((mod.Name, true, null)); + } + catch (Exception ex) + { + results.Add((mod.Name, false, ex.Message)); + } + } + + ModTableRenderer.RenderUpdateSummary(results); + WaitForKey(); + + await RefreshModsAsync(ct); + return false; + } + + private async Task HandleVersionSelectorAsync(CancellationToken ct) + { + var gitMods = _mods.Where(m => m.Source == ModSource.Git).ToList(); + + if (gitMods.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No git-based mods found.[/]"); + WaitForKey(); + return false; + } + + var mod = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[bold]Select a mod to manage:[/]") + .PageSize(15) + .UseConverter(m => + $"{m.Name} [grey]({m.Branch ?? "detached"} @ {m.CurrentCommit?.ShortHash ?? "unknown"})[/]") + .AddChoices(gitMods)); + + // Show current state + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold]{mod.Name}[/]"); + AnsiConsole.MarkupLine($" Branch: [cyan]{mod.Branch ?? "detached HEAD"}[/]"); + AnsiConsole.MarkupLine($" Commit: [grey]{mod.CurrentCommit?.ShortHash ?? "unknown"}[/]"); + + if (mod.HasLocalChanges) + { + AnsiConsole.MarkupLine(" [yellow]⚠ Has local modifications[/]"); + } + + AnsiConsole.WriteLine(); + + var action = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What would you like to do?") + .AddChoices( + "Switch Branch", + "Checkout Specific Commit", + "Back to Main Menu" + )); + + if (action == "Back to Main Menu") + { + return false; + } + + if (mod.HasLocalChanges) + { + AnsiConsole.MarkupLine("[yellow]Warning: You have local changes that may be affected.[/]"); + if (!AnsiConsole.Confirm("Continue anyway?")) + { + return false; + } + } + + if (action == "Switch Branch") + { + await HandleBranchSwitchAsync(mod, ct); + } + else if (action == "Checkout Specific Commit") + { + await HandleCommitCheckoutAsync(mod, ct); + } + + await RefreshModsAsync(ct); + return false; + } + + private async Task HandleBranchSwitchAsync(InstalledMod mod, CancellationToken ct) + { + var branches = await ProgressReporter.WithStatusAsync( + "Fetching branches...", + async () => + { + await _gitService.FetchAsync(mod.Path, ct: ct); + return await _gitService.GetRemoteBranchesAsync(mod.Path, ct); + }); + + if (branches.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No remote branches found.[/]"); + WaitForKey(); + return; + } + + var branch = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select branch:") + .PageSize(15) + .AddChoices(branches)); + + var success = await ProgressReporter.WithStatusAsync( + $"Switching to {branch}...", + async () => await _gitService.CheckoutAsync(mod.Path, branch, ct)); + + if (success) + { + AnsiConsole.MarkupLine($"[green]Switched to branch '{branch}'[/]"); + } + else + { + AnsiConsole.MarkupLine($"[red]Failed to switch to branch '{branch}'[/]"); + } + + WaitForKey(); + } + + private async Task HandleCommitCheckoutAsync(InstalledMod mod, CancellationToken ct) + { + var commits = await ProgressReporter.WithStatusAsync( + "Fetching commit history...", + async () => await _gitService.GetCommitHistoryAsync(mod.Path, 20, ct)); + + if (commits.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No commits found.[/]"); + WaitForKey(); + return; + } + + var commit = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select commit:") + .PageSize(15) + .UseConverter(c => + $"[yellow]{c.ShortHash}[/] [grey]{c.Date.ToLocalTime():yyyy-MM-dd}[/] {Markup.Escape(Truncate(c.Message, 50))}") + .AddChoices(commits)); + + var success = await ProgressReporter.WithStatusAsync( + $"Checking out {commit.ShortHash}...", + async () => await _gitService.CheckoutAsync(mod.Path, commit.Hash, ct)); + + if (success) + { + AnsiConsole.MarkupLine($"[green]Checked out commit {commit.ShortHash}[/]"); + AnsiConsole.MarkupLine("[yellow]Note: You are now in 'detached HEAD' state.[/]"); + } + else + { + AnsiConsole.MarkupLine($"[red]Failed to checkout commit {commit.ShortHash}[/]"); + } + + WaitForKey(); + } + + private async Task HandleRefreshAsync(CancellationToken ct) + { + await RefreshModsAsync(ct); + return false; + } + + private static void WaitForKey() + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[grey]Press any key to continue...[/]"); + Console.ReadKey(true); + } + + private static string Truncate(string text, int maxLength) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) + return text; + return text[..(maxLength - 3)] + "..."; + } +} diff --git a/UI/ModTableRenderer.cs b/UI/ModTableRenderer.cs new file mode 100644 index 0000000..077a155 --- /dev/null +++ b/UI/ModTableRenderer.cs @@ -0,0 +1,174 @@ +using FCPModUpdater.Models; +using Spectre.Console; + +namespace FCPModUpdater.UI; + +public static class ModTableRenderer +{ + public static void RenderModTable(IReadOnlyList mods, int? rateLimit = null, + DateTimeOffset? rateLimitReset = null) + { + if (mods.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No FCP mods found in the specified directory.[/]"); + return; + } + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .Title("[bold blue]FCP Mod Status[/]") + .AddColumn(new TableColumn("[bold]Mod Name[/]").NoWrap()) + .AddColumn(new TableColumn("[bold]Source[/]").Centered()) + .AddColumn(new TableColumn("[bold]Branch[/]").Centered()) + .AddColumn(new TableColumn("[bold]Commit[/]").Centered()) + .AddColumn(new TableColumn("[bold]Status[/]").Centered()); + + foreach (var mod in mods) + { + table.AddRow( + FormatModName(mod), + FormatSource(mod.Source), + FormatBranch(mod.Branch), + FormatCommit(mod.CurrentCommit), + FormatStatus(mod) + ); + } + + AnsiConsole.Write(table); + + // Status summary + var gitMods = mods.Where(m => m.Source == ModSource.Git).ToList(); + var upToDate = gitMods.Count(m => m.Status == ModStatus.UpToDate); + var behind = gitMods.Count(m => m.Status == ModStatus.Behind); + var localChanges = gitMods.Count(m => m.Status == ModStatus.LocalChanges); + var nonGit = mods.Count(m => m.Source != ModSource.Git); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[grey]Summary:[/] [green]{upToDate} up to date[/] | [yellow]{behind} updates available[/] | [cyan]{localChanges} with local changes[/] | [grey]{nonGit} non-git[/]"); + + if (rateLimit.HasValue) + { + var resetTime = rateLimitReset.HasValue + ? $" (resets {rateLimitReset.Value.ToLocalTime():HH:mm})" + : ""; + var color = rateLimit.Value < 10 ? "yellow" : "grey"; + AnsiConsole.MarkupLine($"[{color}]GitHub API: {rateLimit.Value} requests remaining{resetTime}[/]"); + } + } + + private static string FormatModName(InstalledMod mod) + { + var name = mod.Name; + if (mod.HasLocalChanges) + { + name += " [yellow]*[/]"; + } + + return name; + } + + private static string FormatSource(ModSource source) + { + return source switch + { + ModSource.Git => "[green]Git[/]", + ModSource.Local => "[grey]Local[/]", + ModSource.Workshop => "[blue]Workshop[/]", + _ => "[grey]?[/]" + }; + } + + private static string FormatBranch(string? branch) + { + if (string.IsNullOrEmpty(branch)) + return "[grey]-[/]"; + + return branch == "main" || branch == "master" + ? $"[green]{branch}[/]" + : $"[yellow]{branch}[/]"; + } + + private static string FormatCommit(GitCommitInfo? commit) + { + if (commit == null) + return "[grey]-[/]"; + + return $"[grey]{commit.ShortHash}[/]"; + } + + private static string FormatStatus(InstalledMod mod) + { + return mod.Status switch + { + ModStatus.UpToDate => "[green]✓ Up to date[/]", + ModStatus.Behind => $"[yellow]↓ {mod.CommitsBehind} behind[/]", + ModStatus.Ahead => $"[cyan]↑ {mod.CommitsAhead} ahead[/]", + ModStatus.Diverged => $"[red]⇅ Diverged ({mod.CommitsBehind}↓ {mod.CommitsAhead}↑)[/]", + ModStatus.LocalChanges => "[cyan]~ Modified[/]", + ModStatus.NonGit => "[grey]— Not Git[/]", + ModStatus.Error => $"[red]✗ Error[/]", + ModStatus.Unknown => "[grey]? Unknown[/]", + _ => "[grey]?[/]" + }; + } + + public static void RenderUpdateSummary(IReadOnlyList<(string Name, bool Success, string? Error)> results) + { + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .Title("[bold]Update Results[/]") + .AddColumn("[bold]Mod[/]") + .AddColumn("[bold]Result[/]"); + + foreach (var (name, success, error) in results) + { + var result = success + ? "[green]✓ Updated[/]" + : $"[red]✗ Failed: {Markup.Escape(error ?? "Unknown error")}[/]"; + + table.AddRow(name, result); + } + + AnsiConsole.Write(table); + + var successCount = results.Count(r => r.Success); + var failCount = results.Count(r => !r.Success); + + AnsiConsole.WriteLine(); + if (failCount == 0) + { + AnsiConsole.MarkupLine($"[green]Successfully updated {successCount} mod(s).[/]"); + } + else + { + AnsiConsole.MarkupLine( + $"[yellow]Updated {successCount} mod(s), {failCount} failed.[/]"); + } + } + + public static void RenderIncomingCommits(InstalledMod mod, IReadOnlyList commits) + { + if (commits.Count == 0) + { + AnsiConsole.MarkupLine($"[grey]No incoming commits for {mod.Name}[/]"); + return; + } + + var tree = new Tree($"[bold]{mod.Name}[/] [grey]({commits.Count} incoming commits)[/]"); + + foreach (var commit in commits) + { + var dateStr = commit.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + tree.AddNode( + $"[yellow]{commit.ShortHash}[/] [grey]{dateStr}[/] {Markup.Escape(commit.Message)} [grey]— {Markup.Escape(commit.Author)}[/]"); + } + + AnsiConsole.Write(tree); + AnsiConsole.WriteLine(); + } +} diff --git a/UI/ProgressReporter.cs b/UI/ProgressReporter.cs new file mode 100644 index 0000000..debeec4 --- /dev/null +++ b/UI/ProgressReporter.cs @@ -0,0 +1,101 @@ +using Spectre.Console; + +namespace FCPModUpdater.UI; + +public static class ProgressReporter +{ + public static async Task WithStatusAsync(string status, Func> action) + { + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("blue")) + .StartAsync(status, async _ => await action()); + } + + public static async Task WithStatusAsync(string status, Func action) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("blue")) + .StartAsync(status, async _ => await action()); + } + + public static async Task WithProgressAsync( + string description, + IEnumerable<(string Name, Func Action)> tasks) + { + await AnsiConsole.Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new SpinnerColumn()) + .StartAsync(async ctx => + { + var taskList = tasks.ToList(); + var progressTasks = taskList + .Select(t => (Task: ctx.AddTask(t.Name), t.Action)) + .ToList(); + + foreach ((ProgressTask task, var action) in progressTasks) + { + await action(task); + task.Value = 100; + } + }); + } + + public static async Task> WithBatchProgressAsync( + string description, + IReadOnlyList items, + Func nameSelector, + Func, Task<(bool Success, string? Error)>> action) + { + var results = new List<(string Name, bool Success, string? Error)>(); + + await AnsiConsole.Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new SpinnerColumn()) + .StartAsync(async ctx => + { + var overallTask = ctx.AddTask($"[bold]{description}[/]", maxValue: items.Count); + + foreach (var item in items) + { + var name = nameSelector(item); + var itemTask = ctx.AddTask($" {name}"); + + var progress = new Progress(p => itemTask.Value = p); + + try + { + var (success, error) = await action(item, progress); + results.Add((name, success, error)); + itemTask.Value = 100; + + if (!success) + { + itemTask.Description = $" [red]{name}[/]"; + } + else + { + itemTask.Description = $" [green]{name}[/]"; + } + } + catch (Exception ex) + { + results.Add((name, false, ex.Message)); + itemTask.Value = 100; + itemTask.Description = $" [red]{name}[/]"; + } + + overallTask.Increment(1); + } + }); + + return results; + } +}