From 9663e03d79b8fc077efec1be3199045de08e37af Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 20:22:38 +0900 Subject: [PATCH] Add startup update notification --- CHANGELOG.md | 18 + Common/AppDataPaths.cs | 9 +- .../UpdateNotificationServiceTests.cs | 482 +++++++++++++++++ PACKAGE_README.md | 2 + ProgramRunner.cs | 8 + README.md | 4 + Runner/ProgramRunner.Core.cs | 22 +- SECURITY.md | 26 +- Services/UpdateNotificationService.cs | 510 ++++++++++++++++++ USER_GUIDE.md | 46 ++ doc/DEVELOPER_GUIDE.md | 8 + doc/TROUBLESHOOTING.md | 16 + 12 files changed, 1146 insertions(+), 5 deletions(-) create mode 100644 FolderDiffIL4DotNet.Tests/Services/UpdateNotificationServiceTests.cs create mode 100644 Services/UpdateNotificationService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afa4241..4b42c58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### Added + +- **Startup update prompt** — Direct console launches with stdin, stdout, and stderr unredirected now check trusted nuget.org registration metadata for a newer listed stable release and offer `1. Update now` (runs `dotnet tool update --global nildiff`), `2. Skip` for the current run, or `3. Skip until next version`. The third choice stores `dismissedVersion` in the user-local cache, suppressing that release until a newer one is found. A successful update command exits cleanly with restart guidance; a skipped or failed update continues the requested CLI operation. Successful checks use a user-local 20-hour cache, failed checks use a one-hour retry backoff, redirected or automated runs skip the feature, and prereleases or unlisted packages are ignored. Network, response, cache, and updater failures cannot fail the CLI. Affected: `Services/UpdateNotificationService.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Core.cs`, `Common/AppDataPaths.cs`, `README.md`, `USER_GUIDE.md`, `PACKAGE_README.md`, `doc/DEVELOPER_GUIDE.md`, `SECURITY.md`. Tests: `UpdateNotificationServiceTests` (16 tests). + +#### Changed + +- **Startup update prompt spacing** — Successful update and “skip until next version” confirmations now leave a blank line before subsequent terminal output. Affected: `Services/UpdateNotificationService.cs`. Tests: `UpdateNotificationServiceTests`. + #### Documentation +- **Centralized startup-update documentation** — Made `USER_GUIDE.md` the user-facing source of truth for console terminology, Windows/macOS/Linux examples, eligibility, choices, caching, and automation safety. Reduced `README.md` and `PACKAGE_README.md` to summaries with links, while developer, troubleshooting, and security documents now retain only role-specific details and refer back to the guide. - **Simplified the cdidx code-search policy** — Replaced duplicated version-specific setup, freshness, query, fallback, and command examples in `AGENT_GUIDE.md` with a concise requirement to use `cdidx`, identify [Widthdom/CodeIndex](https://github.com/Widthdom/CodeIndex) as its official source, and follow the MCP instructions and tool descriptions or CLI help and diagnostics provided by `cdidx` itself. ### [1.22.0] - 2026-07-26 @@ -1702,8 +1711,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### 追加 + +- **起動時の更新プロンプト** — stdin・stdout・stderr がリダイレクトされていないコンソールから `nildiff` を直接起動したときに、信頼済み nuget.org registration metadata から listed な新しい安定版を確認し、`1. Update now`(`dotnet tool update --global nildiff` を実行)、今回だけ処理を続ける `2. Skip`、その版を通知対象外にする `3. Skip until next version` を選べるようにしました。3 を選ぶとユーザーローカルキャッシュの `dismissedVersion` に対象版を保存し、その版は再表示せず、さらに新しい版が見つかったときだけ再通知します。更新コマンド成功時は再起動案内を表示して正常終了し、スキップまたは更新失敗時は要求された CLI 処理を続行します。確認成功時はユーザーローカルの 20 時間キャッシュ、失敗時は 1 時間の再試行バックオフを使い、リダイレクトまたは自動実行された処理をスキップし、プレリリースと unlisted パッケージを無視します。ネットワーク、レスポンス、キャッシュ、更新処理が失敗しても CLI 本体は失敗しません。影響: `Services/UpdateNotificationService.cs`、`ProgramRunner.cs`、`Runner/ProgramRunner.Core.cs`、`Common/AppDataPaths.cs`、`README.md`、`USER_GUIDE.md`、`PACKAGE_README.md`、`doc/DEVELOPER_GUIDE.md`、`SECURITY.md`。テスト: `UpdateNotificationServiceTests`(16 件)。 + +#### 変更 + +- **起動時更新プロンプトの余白** — 更新成功時と「次の版までスキップ」の確認文の後には、続くターミナル出力との間に空行を残します。影響: `Services/UpdateNotificationService.cs`。テスト: `UpdateNotificationServiceTests`。 + #### ドキュメント +- **起動時更新ドキュメントを集約** — コンソール用語、Windows/macOS/Linux の例、表示条件、選択肢、キャッシュ、自動処理を保護する理由について、`USER_GUIDE.md` を利用者向けの正本にしました。`README.md` と `PACKAGE_README.md` は参照リンク付きの要約へ縮小し、開発者向け、トラブルシューティング、セキュリティ文書には各役割に固有の説明だけを残してユーザーガイドを参照する構成にしました。 - **cdidx コード検索ポリシーを簡素化** — `AGENT_GUIDE.md` に重複していたバージョン依存のセットアップ、鮮度確認、クエリ、フォールバック、コマンド例を削除し、[Widthdom/CodeIndex](https://github.com/Widthdom/CodeIndex) を公式配布元として明示したうえで `cdidx` を使い、`cdidx` 自身が提供する MCP の instructions と tool descriptions、または CLI の help と diagnostics に従うという簡潔な要件へ置き換えました。 ### [1.22.0] - 2026-07-26 diff --git a/Common/AppDataPaths.cs b/Common/AppDataPaths.cs index 7024a428..8714e76e 100644 --- a/Common/AppDataPaths.cs +++ b/Common/AppDataPaths.cs @@ -4,9 +4,9 @@ namespace FolderDiffIL4DotNet.Common { /// - /// Resolves the application's user-local data paths for reports, logs, config, review checklist, and IL cache. + /// Resolves the application's user-local data paths for reports, logs, config, review checklist, update checks, and IL cache. /// Tests can override the LocalApplicationData root via . - /// レポート、ログ、設定、レビューチェックリスト、IL キャッシュ向けのユーザーローカルデータパスを解決します。 + /// レポート、ログ、設定、レビューチェックリスト、更新確認、IL キャッシュ向けのユーザーローカルデータパスを解決します。 /// テストでは で LocalApplicationData ルートを上書きできます。 /// internal static class AppDataPaths @@ -18,6 +18,7 @@ internal static class AppDataPaths private const string HTML_REPORT_DIRECTORY_NAME = "HtmlReport"; private const string CONFIG_FILE_NAME = "config.json"; private const string REVIEW_CHECKLIST_FILE_NAME = "checklist.json"; + private const string UPDATE_CHECK_CACHE_FILE_NAME = "update-check.json"; private const string ERROR_LOCAL_APP_DATA_UNRESOLVED = "LocalApplicationData could not be resolved."; /// @@ -81,6 +82,10 @@ internal static string GetDefaultHtmlReportDirectoryAbsolutePath() internal static string GetDefaultReviewChecklistFileAbsolutePath() => Path.Combine(GetDefaultConfigDirectoryAbsolutePath(), REVIEW_CHECKLIST_FILE_NAME); + /// Gets the update-check cache file path. / 更新確認キャッシュファイルのパスを返します。 + internal static string GetUpdateCheckCacheFileAbsolutePath() + => Path.Combine(GetApplicationDataRootAbsolutePath(), UPDATE_CHECK_CACHE_FILE_NAME); + /// Gets the bundled fallback config file path next to the executable. / 実行ファイル隣にある同梱フォールバック設定ファイルパスを返します。 internal static string GetBundledConfigFileAbsolutePath() => Path.Combine(AppContext.BaseDirectory, CONFIG_FILE_NAME); diff --git a/FolderDiffIL4DotNet.Tests/Services/UpdateNotificationServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/UpdateNotificationServiceTests.cs new file mode 100644 index 00000000..90718ea2 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Services/UpdateNotificationServiceTests.cs @@ -0,0 +1,482 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FolderDiffIL4DotNet.Core.Diagnostics; +using FolderDiffIL4DotNet.Services; +using FolderDiffIL4DotNet.Tests.Helpers; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests.Services +{ + public sealed class UpdateNotificationServiceTests : IDisposable + { + private readonly string _tempDirectory = + Path.Combine(Path.GetTempPath(), "nildiff-update-tests-" + Guid.NewGuid().ToString("N")); + + [Fact] + public async Task TryNotifyAsync_NewerStableVersion_PrintsUpdateCommandAndReleaseNotes() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.22.0", true), ("1.23.0", true)))); + var service = CreateService(handler); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + string notice = output.ToString(); + string expectedLayout = string.Join( + Environment.NewLine, + "✨ Update available! 1.22.0 -> 1.23.0", + string.Empty, + "Run: dotnet tool update --global nildiff", + $"Release notes: {UpdateNotificationService.RELEASE_NOTES_URL}", + string.Empty, + " 1. Update now", + " 2. Skip", + " 3. Skip until next version", + string.Empty, + "Select [1-3] (default: 2; unrecognized input also skips):"); + Assert.Contains(expectedLayout, notice, StringComparison.Ordinal); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_PrereleaseAndUnlistedVersions_UsesLatestListedStableVersion() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex( + ("1.22.0", true), + ("1.23.0-beta.1", true), + ("2.0.0", false), + ("1.24.0", true)))); + var service = CreateService(handler); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Contains("1.22.0 -> 1.24.0", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("2.0.0", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task TryNotifyAsync_CurrentVersionIsLatest_DoesNotPrintNotice() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.22.0", true), ("1.23.0", true)))); + var service = CreateService(handler); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.23.0", output); + + Assert.Equal(string.Empty, output.ToString()); + } + + [Fact] + public async Task TryNotifyAsync_FreshCache_DoesNotIssueAnotherHttpRequest() + { + var now = new DateTimeOffset(2026, 7, 29, 0, 0, 0, TimeSpan.Zero); + string cachePath = Path.Combine(_tempDirectory, "update-check.json"); + var initialHandler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))); + var initialService = CreateService(initialHandler, cachePath, () => now); + + await initialService.TryNotifyAsync("1.22.0", TextWriter.Null); + + var cachedHandler = new RecordingHttpHandler( + _ => throw new InvalidOperationException("HTTP must not be used for a fresh cache.")); + var cachedService = CreateService( + cachedHandler, + cachePath, + () => now.AddHours(UpdateNotificationService.CACHE_VALID_HOURS - 1)); + using var output = new StringWriter(); + + await cachedService.TryNotifyAsync("1.22.0", output); + + Assert.Contains("1.22.0 -> 1.23.0", output.ToString(), StringComparison.Ordinal); + Assert.Equal(0, cachedHandler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_StaleCache_RefreshesFromNuGet() + { + var now = new DateTimeOffset(2026, 7, 29, 0, 0, 0, TimeSpan.Zero); + string cachePath = Path.Combine(_tempDirectory, "update-check.json"); + var initialService = CreateService( + new RecordingHttpHandler(_ => JsonResponse(RegistrationIndex(("1.23.0", true)))), + cachePath, + () => now); + await initialService.TryNotifyAsync("1.22.0", TextWriter.Null); + + var refreshHandler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.24.0", true)))); + var refreshService = CreateService( + refreshHandler, + cachePath, + () => now.AddHours(UpdateNotificationService.CACHE_VALID_HOURS)); + using var output = new StringWriter(); + + await refreshService.TryNotifyAsync("1.22.0", output); + + Assert.Contains("1.22.0 -> 1.24.0", output.ToString(), StringComparison.Ordinal); + Assert.Equal(1, refreshHandler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_NuGetFailure_IsSilent() + { + var now = new DateTimeOffset(2026, 7, 29, 0, 0, 0, TimeSpan.Zero); + string cachePath = Path.Combine(_tempDirectory, "update-check.json"); + var handler = new RecordingHttpHandler( + _ => throw new HttpRequestException("nuget unavailable")); + var service = CreateService(handler, cachePath, () => now); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Equal(string.Empty, output.ToString()); + Assert.Equal(1, handler.RequestCount); + + var backoffHandler = new RecordingHttpHandler( + _ => throw new InvalidOperationException("HTTP must not be retried during backoff.")); + var backoffService = CreateService( + backoffHandler, + cachePath, + () => now.AddMinutes(30)); + + await backoffService.TryNotifyAsync("1.22.0", output); + + Assert.Equal(0, backoffHandler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_CheckDisabled_DoesNotReadCacheOrUseNetwork() + { + var handler = new RecordingHttpHandler( + _ => throw new InvalidOperationException("HTTP must not be used when disabled.")); + var service = new UpdateNotificationService( + new HttpClient(handler), + () => throw new InvalidOperationException("Cache must not be read when disabled."), + static () => DateTimeOffset.UtcNow, + static () => false); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Equal(string.Empty, output.ToString()); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_EnvironmentDetectionFailure_IsSilent() + { + var handler = new RecordingHttpHandler( + _ => throw new InvalidOperationException("HTTP must not be used.")); + var service = new UpdateNotificationService( + new HttpClient(handler), + () => throw new InvalidOperationException("Cache must not be read."), + static () => DateTimeOffset.UtcNow, + static () => throw new IOException("console state unavailable")); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Equal(string.Empty, output.ToString()); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_ExternalRegistrationPage_FollowsTrustedNuGetUrl() + { + const string pageUrl = + "https://api.nuget.org/v3/registration5-gz-semver2/nildiff/page/1.0.0/1.23.0.json"; + var handler = new RecordingHttpHandler(request => + { + string json = string.Equals(request.RequestUri?.AbsoluteUri, pageUrl, StringComparison.Ordinal) + ? "{\"items\":[{\"catalogEntry\":{\"version\":\"1.23.0\",\"listed\":true}}]}" + : "{\"items\":[{\"@id\":\"" + pageUrl + "\"}]}"; + return JsonResponse(json); + }); + var service = CreateService(handler); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Contains("1.22.0 -> 1.23.0", output.ToString(), StringComparison.Ordinal); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_ExternalRegistrationPage_DoesNotFollowUntrustedHost() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + "{\"items\":[{\"@id\":\"https://example.test/forged-page.json\"}]}")); + var service = CreateService(handler); + using var output = new StringWriter(); + + await service.TryNotifyAsync("1.22.0", output); + + Assert.Equal(string.Empty, output.ToString()); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_UpdateSelected_RunsCommandAndRequestsCallerExit() + { + int updateCommandCalls = 0; + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))); + var service = CreateService( + handler, + runUpdateCommand: _ => + { + updateCommandCalls++; + return Task.FromResult(0); + }); + using var output = new StringWriter(); + + bool shouldExit = await service.TryNotifyAsync( + "1.22.0", + output, + new StringReader("1\n")); + + Assert.True(shouldExit); + Assert.Equal(1, updateCommandCalls); + Assert.Contains("Please restart nildiff", output.ToString(), StringComparison.Ordinal); + Assert.EndsWith( + "Update command completed successfully. Please restart nildiff." + + Environment.NewLine + + Environment.NewLine, + output.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task TryNotifyAsync_SkipSelected_DoesNotRunUpdateCommand() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))); + var service = CreateService( + handler, + runUpdateCommand: _ => throw new InvalidOperationException("Update must not run.")); + using var output = new StringWriter(); + + bool shouldExit = await service.TryNotifyAsync( + "1.22.0", + output, + new StringReader("2\n")); + + Assert.False(shouldExit); + Assert.DoesNotContain("Updating nildiff via", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task TryNotifyAsync_SkipUntilNextVersionSelected_DismissesCurrentLatest() + { + var now = new DateTimeOffset(2026, 7, 29, 0, 0, 0, TimeSpan.Zero); + string cachePath = Path.Combine(_tempDirectory, "update-check.json"); + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))); + var service = CreateService(handler, cachePath, () => now); + using var firstOutput = new StringWriter(); + + bool shouldExit = await service.TryNotifyAsync( + "1.22.0", + firstOutput, + new StringReader("3\n")); + + Assert.False(shouldExit); + Assert.Contains( + "Skipped 1.23.0. You will be notified when a newer version is available.", + firstOutput.ToString(), + StringComparison.Ordinal); + Assert.EndsWith( + "Skipped 1.23.0. You will be notified when a newer version is available." + + Environment.NewLine + + Environment.NewLine, + firstOutput.ToString(), + StringComparison.Ordinal); + + var cachedHandler = new RecordingHttpHandler( + _ => throw new InvalidOperationException("HTTP must not be used for a fresh cache.")); + var cachedService = CreateService( + cachedHandler, + cachePath, + () => now.AddHours(1)); + using var secondOutput = new StringWriter(); + + await cachedService.TryNotifyAsync( + "1.22.0", + secondOutput, + new StringReader("1\n")); + + Assert.Equal(string.Empty, secondOutput.ToString()); + Assert.Equal(0, cachedHandler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_SkipUntilNextVersionSelected_NewerVersionPromptsAgain() + { + var now = new DateTimeOffset(2026, 7, 29, 0, 0, 0, TimeSpan.Zero); + string cachePath = Path.Combine(_tempDirectory, "update-check.json"); + var initialService = CreateService( + new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))), + cachePath, + () => now); + + await initialService.TryNotifyAsync( + "1.22.0", + TextWriter.Null, + new StringReader("3\n")); + + var refreshHandler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.24.0", true)))); + var refreshService = CreateService( + refreshHandler, + cachePath, + () => now.AddHours(UpdateNotificationService.CACHE_VALID_HOURS)); + using var output = new StringWriter(); + + await refreshService.TryNotifyAsync( + "1.22.0", + output, + new StringReader("2\n")); + + Assert.Contains("1.22.0 -> 1.24.0", output.ToString(), StringComparison.Ordinal); + Assert.Equal(1, refreshHandler.RequestCount); + } + + [Fact] + public async Task TryNotifyAsync_UpdateCommandFails_ReportsFailureAndContinues() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("1.23.0", true)))); + var service = CreateService( + handler, + runUpdateCommand: _ => Task.FromResult(17)); + using var output = new StringWriter(); + + bool shouldExit = await service.TryNotifyAsync( + "1.22.0", + output, + new StringReader("1\n")); + + Assert.False(shouldExit); + Assert.Contains("Update failed", output.ToString(), StringComparison.Ordinal); + Assert.Contains("exited with code 17", output.ToString(), StringComparison.Ordinal); + Assert.Contains(UpdateNotificationService.UPDATE_COMMAND, output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task RunAsync_VersionFlag_WithAvailableUpdate_PreservesStdoutAndWritesNoticeToStderr() + { + var handler = new RecordingHttpHandler(_ => JsonResponse( + RegistrationIndex(("99.0.0", true)))); + var updateService = CreateService(handler); + var runner = new ProgramRunner( + new TestLogger(logFileAbsolutePath: "test.log"), + new ConfigService(), + static _ => { }, + updateService); + TextWriter originalOut = Console.Out; + TextWriter originalError = Console.Error; + TextReader originalIn = Console.In; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + Console.SetOut(stdout); + Console.SetError(stderr); + Console.SetIn(new StringReader("2\n")); + + try + { + int exitCode = await runner.RunAsync(["--version"]); + + Assert.Equal(0, exitCode); + Assert.Equal( + SystemInfo.GetAppVersion(typeof(Program)), + stdout.ToString().Trim()); + Assert.Contains("Update available!", stderr.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + Console.SetIn(originalIn); + } + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + private UpdateNotificationService CreateService( + RecordingHttpHandler handler, + string? cachePath = null, + Func? utcNowProvider = null, + Func>? runUpdateCommand = null) + { + cachePath ??= Path.Combine(_tempDirectory, "update-check.json"); + utcNowProvider ??= static () => DateTimeOffset.UtcNow; + return new UpdateNotificationService( + new HttpClient(handler), + () => cachePath, + utcNowProvider, + static () => true, + runUpdateCommand); + } + + private static string RegistrationIndex(params (string Version, bool Listed)[] versions) + { + var entries = new StringBuilder(); + for (int i = 0; i < versions.Length; i++) + { + if (i > 0) + { + entries.Append(','); + } + + entries.Append("{\"catalogEntry\":{\"version\":\""); + entries.Append(versions[i].Version); + entries.Append("\",\"listed\":"); + entries.Append(versions[i].Listed ? "true" : "false"); + entries.Append("}}"); + } + + return "{\"items\":[{\"items\":[" + entries + "]}]}"; + } + + private static HttpResponseMessage JsonResponse(string json) + => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class RecordingHttpHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + internal RecordingHttpHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + internal int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + return Task.FromResult(_responseFactory(request)); + } + } + } +} diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 6fa2f7a9..b45a70c9 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -8,6 +8,8 @@ dotnet tool install -g nildiff ``` +Direct launches from an interactive console may offer an update or skip prompt; redirected, piped, and automated runs do not. See the [startup update notification guide](https://github.com/Widthdom/FolderDiffIL4DotNet/blob/main/USER_GUIDE.md#guide-en-startup-update-notification) for choices, caching, platform examples, and the exact eligibility rule. + ## Usage ```bash diff --git a/ProgramRunner.cs b/ProgramRunner.cs index af4e7a34..bdd8bf5c 100644 --- a/ProgramRunner.cs +++ b/ProgramRunner.cs @@ -26,6 +26,14 @@ public sealed partial class ProgramRunner public async Task RunAsync(string[] args) { var opts = CliParser.Parse(args); + bool updateCompleted = await _updateNotificationService.TryNotifyAsync( + SystemInfo.GetAppVersion(typeof(Program)), + Console.Error, + Console.In); + if (updateCompleted) + { + return 0; + } if (opts.ShowHelp) { diff --git a/README.md b/README.md index fce8ba32..e94f066e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ nildiff --doctor `nildiff --version` prints the same public SemVer used by the GitHub release and NuGet package. `nildiff --doctor` retains the detailed build/commit version for diagnostics. +When launched directly from an interactive console with stdin, stdout, and stderr unredirected, nildiff checks for a newer stable release and may offer update or skip choices. Redirected, piped, and automated runs skip the prompt. See [Startup update notification](USER_GUIDE.md#guide-en-startup-update-notification) for cache behavior, choices, platform examples, and the exact eligibility rule. + Build from source: ```bash @@ -170,6 +172,8 @@ nildiff --doctor `nildiff --version` は GitHub リリースおよび NuGet パッケージと同じ公開 SemVer を表示します。`nildiff --doctor` では診断用の詳細なビルド/コミットバージョンを引き続き確認できます。 +stdin・stdout・stderr がリダイレクトされていない対話型コンソールから直接起動すると、nildiff は新しい安定版を確認し、更新またはスキップの選択肢を表示する場合があります。リダイレクト、パイプ、自動実行ではプロンプトをスキップします。キャッシュ、各選択肢、OS 別の例、正確な判定条件は[起動時の更新通知](USER_GUIDE.md#guide-ja-startup-update-notification)を参照してください。 + ソースからビルドする: ```bash diff --git a/Runner/ProgramRunner.Core.cs b/Runner/ProgramRunner.Core.cs index d3c7d9c0..b37f14d2 100644 --- a/Runner/ProgramRunner.Core.cs +++ b/Runner/ProgramRunner.Core.cs @@ -27,6 +27,7 @@ public sealed partial class ProgramRunner private readonly ILoggerService _logger; private readonly ConfigService _configService; private readonly Action _openFolderAction; + private readonly UpdateNotificationService _updateNotificationService; /// /// Initializes a new instance of . @@ -35,7 +36,11 @@ public sealed partial class ProgramRunner /// Logger for diagnostic output. / 診断出力用ロガー。 /// Service for loading configuration files. / 設定ファイル読込サービス。 public ProgramRunner(ILoggerService logger, ConfigService configService) - : this(logger, configService, static processStartInfo => Process.Start(processStartInfo)) + : this( + logger, + configService, + static processStartInfo => Process.Start(processStartInfo), + new UpdateNotificationService()) { } @@ -47,14 +52,29 @@ public ProgramRunner(ILoggerService logger, ConfigService configService) /// Service for loading configuration files. / 設定ファイル読込サービス。 /// Action used by `--open-*` commands to launch the folder. / `--open-*` コマンドでフォルダを起動するためのアクション。 internal ProgramRunner(ILoggerService logger, ConfigService configService, Action openFolderAction) + : this(logger, configService, openFolderAction, new UpdateNotificationService()) + { + } + + /// + /// Initializes a testable runner with replaceable folder-open and update-notification services. + /// フォルダ開放処理と更新通知サービスを差し替え可能にした、テスト向けランナーを初期化します。 + /// + internal ProgramRunner( + ILoggerService logger, + ConfigService configService, + Action openFolderAction, + UpdateNotificationService updateNotificationService) { ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(configService); ArgumentNullException.ThrowIfNull(openFolderAction); + ArgumentNullException.ThrowIfNull(updateNotificationService); _logger = logger; _configService = configService; _openFolderAction = openFolderAction; + _updateNotificationService = updateNotificationService; } } } diff --git a/SECURITY.md b/SECURITY.md index c2bf4bcc..72e4630f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,8 +61,9 @@ The advisory will credit the reporter when requested and appropriate. 1. User input: CLI arguments, `config.json` / `config.jsonc`, and `FOLDERDIFF_*` environment variables 2. File system: old/new folder contents, report output directory, IL disassembly cache -3. External tools: `dotnet-ildasm` and `ilspycmd` +3. External tools: `dotnet-ildasm`, `ilspycmd`, and the user-selected `dotnet tool update` process 4. Browser: local HTML report rendering +5. Network: trusted nuget.org package-registration metadata used by the console-startup update check #### Threats and Mitigations @@ -94,6 +95,7 @@ The advisory will credit the reporter when requested and appropriate. - Inline diff cost is capped - Disassembler timeout is configurable - Failing disassemblers can be blacklisted +- The startup update request has a two-second timeout, a 20-hour success cache, and a one-hour failure backoff ##### Elevation of Privilege @@ -110,6 +112,7 @@ The advisory will credit the reporter when requested and appropriate. #### Subprocess Security - Disassembler commands are hardcoded candidates, not arbitrary user commands +- The update prompt runs only the fixed `dotnet tool update --global nildiff` executable and argument list without a shell - External tool paths are resolved via `PATH` or configuration - Each disassembler invocation has a configurable timeout - Non-ASCII paths are handled via temporary ASCII-safe copies @@ -120,6 +123,14 @@ The advisory will credit the reporter when requested and appropriate. - Configuration is validated at startup - The tool does not store or require secrets +#### Update-Check Network Security + +- The update-check phase requires stdin, stdout, and stderr all to be unredirected, preventing it from consuming piped input, blocking automation, or adding update text to redirected output; see the [user guide](USER_GUIDE.md#guide-en-startup-update-notification) for platform examples and user-facing behavior +- The client requests fixed HTTPS nuget.org registration metadata and follows registration-page links only when they remain on `https://api.nuget.org` +- No compared paths, report data, configuration, or credentials are sent +- Update execution requires an explicit `1. Update now` selection; `2. Skip` and `3. Skip until next version` run no process, and choice 3 only stores the dismissed public package version in the user-local cache +- Network, response, cache, and updater failures are contained and cannot fail the requested command + #### Known Limitations | Limitation | Rationale | @@ -189,8 +200,9 @@ The advisory will credit the reporter when requested and appropriate. 1. ユーザー入力: CLI 引数、`config.json` / `config.jsonc`、`FOLDERDIFF_*` 環境変数 2. ファイルシステム: 比較対象の旧/新フォルダ、レポート出力先、IL キャッシュ -3. 外部ツール: `dotnet-ildasm` と `ilspycmd` +3. 外部ツール: `dotnet-ildasm`、`ilspycmd`、ユーザー選択時の `dotnet tool update` プロセス 4. ブラウザ: ローカル HTML レポートのレンダリング +5. ネットワーク: コンソール起動時の更新確認で使う、信頼済み nuget.org パッケージ registration metadata #### 脅威と緩和策 @@ -222,6 +234,7 @@ The advisory will credit the reporter when requested and appropriate. - インライン差分の計算コストを制限する - 逆アセンブラのタイムアウトを設定できる - 失敗する逆アセンブラはブラックリスト化できる +- 起動時の更新リクエストは 2 秒でタイムアウトし、成功時は 20 時間キャッシュ、失敗時は 1 時間バックオフする ##### 権限昇格 @@ -238,6 +251,7 @@ The advisory will credit the reporter when requested and appropriate. #### サブプロセスセキュリティ - 逆アセンブラ候補はハードコードされたコマンドであり、任意コマンドではない +- 更新プロンプトは shell を介さず、固定の `dotnet tool update --global nildiff` 実行ファイルと引数だけを起動する - 外部ツールパスは `PATH` または設定から解決する - 各逆アセンブラ呼び出しにはタイムアウトがある - 非 ASCII パスは一時的な ASCII セーフコピーで扱う @@ -248,6 +262,14 @@ The advisory will credit the reporter when requested and appropriate. - 設定は起動時に検証する - ツールはシークレットを保存も要求もしない +#### 更新確認のネットワークセキュリティ + +- 更新確認フェーズでは stdin・stdout・stderr のすべてがリダイレクトされていないことを必須とし、パイプ入力の消費、自動処理の入力待ち、リダイレクト出力への更新文言混入を防ぐ。OS 別の例と利用者向けの挙動は[ユーザーガイド](USER_GUIDE.md#guide-ja-startup-update-notification)を参照 +- 固定の HTTPS nuget.org registration metadata を要求し、registration page のリンクは `https://api.nuget.org` 上に留まる場合だけ追跡する +- 比較対象パス、レポートデータ、設定、認証情報は送信しない +- 更新処理は `1. Update now` の明示選択時だけ実行し、`2. Skip` と `3. Skip until next version` ではプロセスを起動しない。3 でユーザーローカルキャッシュに保存するのは、通知対象外にした公開パッケージ版だけ +- ネットワーク、レスポンス、キャッシュ、更新処理の失敗は内部で処理し、要求されたコマンドを失敗させない + #### 既知の制限事項 | 制限 | 理由 | diff --git a/Services/UpdateNotificationService.cs b/Services/UpdateNotificationService.cs new file mode 100644 index 00000000..e6a88591 --- /dev/null +++ b/Services/UpdateNotificationService.cs @@ -0,0 +1,510 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FolderDiffIL4DotNet.Common; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// Checks nuget.org for a newer stable nildiff release and prints a best-effort startup notice. + /// nuget.org で nildiff の新しい安定版を確認し、ベストエフォートで起動通知を表示します。 + /// + internal sealed class UpdateNotificationService + { + internal const string PACKAGE_REGISTRATION_URL = + "https://api.nuget.org/v3/registration5-gz-semver2/nildiff/index.json"; + internal const string RELEASE_NOTES_URL = + "https://github.com/Widthdom/FolderDiffIL4DotNet/releases/latest"; + internal const string UPDATE_COMMAND = "dotnet tool update --global nildiff"; + internal const int CACHE_VALID_HOURS = 20; + internal const int FAILURE_RETRY_HOURS = 1; + + private const int HTTP_TIMEOUT_SECONDS = 2; + private const string NUGET_API_HOST = "api.nuget.org"; + private static readonly HttpClient s_httpClient = CreateDefaultHttpClient(); + + private readonly HttpClient _httpClient; + private readonly Func _cachePathResolver; + private readonly Func _utcNowProvider; + private readonly Func _shouldCheck; + private readonly Func> _runUpdateCommand; + + internal UpdateNotificationService() + : this( + s_httpClient, + AppDataPaths.GetUpdateCheckCacheFileAbsolutePath, + static () => DateTimeOffset.UtcNow, + ShouldCheckInCurrentEnvironment, + RunUpdateCommandAsync) + { + } + + internal UpdateNotificationService( + HttpClient httpClient, + Func cachePathResolver, + Func utcNowProvider, + Func shouldCheck, + Func>? runUpdateCommand = null) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(cachePathResolver); + ArgumentNullException.ThrowIfNull(utcNowProvider); + ArgumentNullException.ThrowIfNull(shouldCheck); + + _httpClient = httpClient; + _cachePathResolver = cachePathResolver; + _utcNowProvider = utcNowProvider; + _shouldCheck = shouldCheck; + _runUpdateCommand = runUpdateCommand ?? RunUpdateCommandAsync; + } + + /// + /// Prompts to install a newer stable release when one is known. + /// Returns only when the update command succeeds and the caller should exit. + /// 新しい安定版が確認できた場合に更新選択を表示します。 + /// 更新コマンドが成功し、呼び出し元が終了すべき場合だけ を返します。 + /// + internal async Task TryNotifyAsync( + string currentVersion, + TextWriter output, + TextReader? input = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(currentVersion); + ArgumentNullException.ThrowIfNull(output); + +#pragma warning disable CA1031 // Update checks must never prevent the CLI from starting. + try + { + if (!_shouldCheck()) + { + return false; + } + + UpdateCheckCache? cache; + using (var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeoutSource.CancelAfter(TimeSpan.FromSeconds(HTTP_TIMEOUT_SECONDS)); + cache = await GetUpdateCheckCacheAsync(timeoutSource.Token); + } + + string? latestVersion = cache?.LatestVersion; + if (!IsNewerStableVersion(latestVersion, currentVersion) + || string.Equals( + latestVersion, + cache?.DismissedVersion, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + await output.WriteLineAsync(); + await output.WriteLineAsync($"✨ Update available! {currentVersion} -> {latestVersion}"); + await output.WriteLineAsync(); + await output.WriteLineAsync($"Run: {UPDATE_COMMAND}"); + await output.WriteLineAsync($"Release notes: {RELEASE_NOTES_URL}"); + await output.WriteLineAsync(); + await output.WriteLineAsync(" 1. Update now"); + await output.WriteLineAsync(" 2. Skip"); + await output.WriteLineAsync(" 3. Skip until next version"); + await output.WriteLineAsync(); + await output.WriteAsync( + "Select [1-3] (default: 2; unrecognized input also skips): "); + await output.FlushAsync(cancellationToken); + + string? selection = await (input ?? TextReader.Null).ReadLineAsync(cancellationToken); + string trimmedSelection = selection?.Trim() ?? string.Empty; + if (string.Equals(trimmedSelection, "3", StringComparison.Ordinal)) + { + await TryWriteCacheAsync( + cache! with { DismissedVersion = latestVersion }, + CancellationToken.None); + await output.WriteLineAsync(); + await output.WriteLineAsync( + $"Skipped {latestVersion}. You will be notified when a newer version is available."); + await output.WriteLineAsync(); + await output.FlushAsync(cancellationToken); + return false; + } + + if (!string.Equals(trimmedSelection, "1", StringComparison.Ordinal)) + { + await output.WriteLineAsync(); + return false; + } + + await output.WriteLineAsync(); + await output.WriteLineAsync($"Updating nildiff via `{UPDATE_COMMAND}`..."); + await output.FlushAsync(cancellationToken); + + try + { + int exitCode = await _runUpdateCommand(cancellationToken); + if (exitCode == 0) + { + await output.WriteLineAsync(); + await output.WriteLineAsync("Update command completed successfully. Please restart nildiff."); + await output.WriteLineAsync(); + await output.FlushAsync(cancellationToken); + return true; + } + + await WriteUpdateFailureAsync(output, $"The update command exited with code {exitCode}."); + return false; + } + catch (Exception) + { + await WriteUpdateFailureAsync(output, "The update command could not be completed."); + return false; + } + } + catch (Exception) + { + // Update notification is informational and must never alter CLI behavior. + // 更新通知は情報提供のみであり、CLI 本体の挙動を変えてはいけません。 + return false; + } +#pragma warning restore CA1031 + } + + internal static bool IsNewerStableVersion(string? candidateVersion, string currentVersion) + { + if (!TryParseStableVersion(candidateVersion, out var candidate) + || !TryParseStableVersion(currentVersion, out var current)) + { + return false; + } + + return candidate > current; + } + + private async Task GetUpdateCheckCacheAsync( + CancellationToken cancellationToken) + { + DateTimeOffset now = _utcNowProvider(); + UpdateCheckCache? cached = await TryReadCacheAsync(cancellationToken); + if (cached != null) + { + DateTimeOffset nextCheckAtUtc = cached.NextCheckAtUtc + ?? cached.CheckedAtUtc.AddHours(CACHE_VALID_HOURS); + TimeSpan untilNextCheck = nextCheckAtUtc - now; + if (untilNextCheck > TimeSpan.Zero + && untilNextCheck <= TimeSpan.FromHours(CACHE_VALID_HOURS)) + { + return cached; + } + } + + try + { + string? latestVersion = await FetchLatestStableVersionAsync(cancellationToken); + if (latestVersion == null) + { + return await WriteFailureBackoffCacheAsync(now, cached); + } + + var refreshedCache = new UpdateCheckCache( + now, + latestVersion, + now.AddHours(CACHE_VALID_HOURS), + cached?.DismissedVersion); + await TryWriteCacheAsync(refreshedCache, CancellationToken.None); + return refreshedCache; + } + catch (HttpRequestException) + { + return await WriteFailureBackoffCacheAsync(now, cached); + } + catch (OperationCanceledException) + { + return await WriteFailureBackoffCacheAsync(now, cached); + } + catch (JsonException) + { + return await WriteFailureBackoffCacheAsync(now, cached); + } + } + + private async Task WriteFailureBackoffCacheAsync( + DateTimeOffset now, + UpdateCheckCache? cached) + { + var failureBackoffCache = new UpdateCheckCache( + now, + cached?.LatestVersion, + now.AddHours(FAILURE_RETRY_HOURS), + cached?.DismissedVersion); + await TryWriteCacheAsync(failureBackoffCache, CancellationToken.None); + return failureBackoffCache; + } + + private async Task FetchLatestStableVersionAsync(CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, PACKAGE_REGISTRATION_URL); + request.Headers.TryAddWithoutValidation("User-Agent", "nildiff-update-check"); + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + using JsonDocument registrationIndex = await JsonDocument.ParseAsync( + responseStream, + cancellationToken: cancellationToken); + + if (!registrationIndex.RootElement.TryGetProperty("items", out var pages) + || pages.ValueKind != JsonValueKind.Array) + { + return null; + } + + Version? latest = null; + string? latestText = null; + + foreach (JsonElement page in pages.EnumerateArray()) + { + if (page.TryGetProperty("items", out var inlineItems) + && inlineItems.ValueKind == JsonValueKind.Array) + { + FindLatestStableVersion(inlineItems, ref latest, ref latestText); + continue; + } + + if (!TryGetTrustedPageUri(page, out var pageUri)) + { + continue; + } + + using var pageResponse = await _httpClient.GetAsync( + pageUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + pageResponse.EnsureSuccessStatusCode(); + + await using var pageStream = await pageResponse.Content.ReadAsStreamAsync(cancellationToken); + using JsonDocument pageDocument = await JsonDocument.ParseAsync( + pageStream, + cancellationToken: cancellationToken); + if (pageDocument.RootElement.TryGetProperty("items", out var pageItems) + && pageItems.ValueKind == JsonValueKind.Array) + { + FindLatestStableVersion(pageItems, ref latest, ref latestText); + } + } + + return latestText; + } + + private static void FindLatestStableVersion( + JsonElement leaves, + ref Version? latest, + ref string? latestText) + { + foreach (JsonElement leaf in leaves.EnumerateArray()) + { + if (!leaf.TryGetProperty("catalogEntry", out var catalogEntry) + || catalogEntry.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (catalogEntry.TryGetProperty("listed", out var listed) + && listed.ValueKind == JsonValueKind.False) + { + continue; + } + + if (!catalogEntry.TryGetProperty("version", out var versionElement) + || versionElement.ValueKind != JsonValueKind.String) + { + continue; + } + + string? versionText = versionElement.GetString(); + if (!TryParseStableVersion(versionText, out var parsedVersion) + || (latest != null && parsedVersion <= latest)) + { + continue; + } + + latest = parsedVersion; + latestText = versionText; + } + } + + private static bool TryParseStableVersion(string? versionText, out Version version) + { + version = new Version(); + if (string.IsNullOrWhiteSpace(versionText) + || versionText.Contains('-', StringComparison.Ordinal) + || !Version.TryParse(versionText, out var parsedVersion) + || parsedVersion == null) + { + return false; + } + + version = parsedVersion; + return true; + } + + private static bool TryGetTrustedPageUri(JsonElement page, out Uri? pageUri) + { + pageUri = null; + if (!page.TryGetProperty("@id", out var id) + || id.ValueKind != JsonValueKind.String + || !Uri.TryCreate(id.GetString(), UriKind.Absolute, out var parsedUri) + || !string.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.Equals(parsedUri.Host, NUGET_API_HOST, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + pageUri = parsedUri; + return true; + } + + private async Task TryReadCacheAsync(CancellationToken cancellationToken) + { +#pragma warning disable CA1031 // Cache failures must not prevent the network check or CLI startup. + try + { + string cachePath = _cachePathResolver(); + if (!File.Exists(cachePath)) + { + return null; + } + + await using var stream = File.OpenRead(cachePath); + return await JsonSerializer.DeserializeAsync( + stream, + cancellationToken: cancellationToken); + } + catch (Exception) + { + return null; + } +#pragma warning restore CA1031 + } + + private async Task TryWriteCacheAsync( + UpdateCheckCache cache, + CancellationToken cancellationToken) + { + string? temporaryPath = null; +#pragma warning disable CA1031 // Cache writes are best effort and must not affect CLI startup. + try + { + string cachePath = _cachePathResolver(); + string? cacheDirectory = Path.GetDirectoryName(cachePath); + if (string.IsNullOrWhiteSpace(cacheDirectory)) + { + return; + } + + Directory.CreateDirectory(cacheDirectory); + temporaryPath = cachePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true)) + { + await JsonSerializer.SerializeAsync( + stream, + cache, + cancellationToken: cancellationToken); + } + + File.Move(temporaryPath, cachePath, overwrite: true); + temporaryPath = null; + } + catch (Exception) + { + // Ignore cache persistence failures. + // キャッシュ永続化の失敗は無視します。 + } + finally + { + if (temporaryPath != null) + { + try + { + File.Delete(temporaryPath); + } + catch (Exception) + { + // Ignore temporary-file cleanup failures. + // 一時ファイルの削除失敗は無視します。 + } + } + } +#pragma warning restore CA1031 + } + + private static bool ShouldCheckInCurrentEnvironment() + => !Console.IsInputRedirected + && !Console.IsOutputRedirected + && !Console.IsErrorRedirected; + + private static async Task RunUpdateCommandAsync(CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = Constants.DOTNET_MUXER, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("tool"); + startInfo.ArgumentList.Add("update"); + startInfo.ArgumentList.Add("--global"); + startInfo.ArgumentList.Add("nildiff"); + + using var process = Process.Start(startInfo); + if (process == null) + { + return -1; + } + + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode; + } + + private static async Task WriteUpdateFailureAsync(TextWriter output, string detail) + { + await output.WriteLineAsync(); + await output.WriteLineAsync($"Update failed. {detail}"); + await output.WriteLineAsync($"Run `{UPDATE_COMMAND}` manually to retry."); + await output.FlushAsync(); + } + + private static HttpClient CreateDefaultHttpClient() + { + var handler = new HttpClientHandler + { + AutomaticDecompression = + DecompressionMethods.GZip + | DecompressionMethods.Deflate + | DecompressionMethods.Brotli + }; + return new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(HTTP_TIMEOUT_SECONDS) + }; + } + + private sealed record UpdateCheckCache( + [property: JsonPropertyName("checkedAtUtc")] DateTimeOffset CheckedAtUtc, + [property: JsonPropertyName("latestVersion")] string? LatestVersion, + [property: JsonPropertyName("nextCheckAtUtc")] DateTimeOffset? NextCheckAtUtc, + [property: JsonPropertyName("dismissedVersion")] string? DismissedVersion); + } +} diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 371835cf..22003792 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -78,6 +78,28 @@ nildiff "/path/to/old-folder" "/path/to/new-folder" "my-comparison" --no-pause The default output root is the user-local app-data folder for your OS. See [`doc/config.sample.jsonc`](doc/config.sample.jsonc) for configuration details and `nildiff --open-reports` to jump to the generated report folder. + +### Startup update notification + +Direct console launches that meet the eligibility rule below perform a best-effort update check against nuget.org. Successful checks cache the latest stable version in `/update-check.json` for 20 hours; failed checks store a one-hour retry backoff, preventing repeated startup delays during an outage. If a newer version is available, stderr offers `1. Update now` (runs `dotnet tool update --global nildiff`), `2. Skip`, or `3. Skip until next version`. Choice 2 applies only to the current run. Choice 3 records the dismissed version in the same cache, suppresses that release, and prompts again only after a newer release is found. Update success exits with restart guidance; skipped or failed updates continue the requested command. + +For this feature, “launched directly from an interactive console” means that standard input, standard output, and standard error are all **not redirected**. This wording includes direct launches from Command Prompt, PowerShell, a shell hosted by Windows Terminal, and ordinary macOS/Linux console sessions; it does not require an application specifically named “Terminal.” The check runs before command dispatch, so a directly invoked `nildiff --help` or `nildiff --version` is also eligible. If even one stream is redirected or piped, the entire update-check phase is skipped: + +```bash +# Eligible: stdin, stdout, and stderr are all unredirected +nildiff --version +nildiff "/path/to/old-folder" "/path/to/new-folder" + +# Skipped: stdout or stderr is redirected +nildiff --version > version.txt +nildiff "/path/to/old-folder" "/path/to/new-folder" 2> errors.txt + +# Skipped: stdin is piped +printf 'input\n' | nildiff "/path/to/old-folder" "/path/to/new-folder" +``` + +CI runners, scheduled jobs, and other non-interactive launches usually fall into the skipped category. Requiring all three streams to remain unredirected prevents the prompt from consuming piped input, blocking automation, or adding update text to redirected command output. + ### Option B: Clone and build from source ```bash @@ -118,6 +140,7 @@ The tool compares all files recursively. For .NET assemblies (`.dll`, `.exe`), i | Need | Document | | --- | --- | | Get started in 5 minutes | [USER_GUIDE.md](USER_GUIDE.md#readme-en-quick-start) | +| Understand startup update checks and prompts | [Startup update notification](#guide-en-startup-update-notification) | | Product overview, setup, usage, and configuration | [USER_GUIDE.md](USER_GUIDE.md#readme-en-usage) | | Assembly semantic change detection | [USER_GUIDE.md](USER_GUIDE.md#readme-en-assembly-semantic-changes) | | Configuration reference with annotated sample | [doc/config.sample.jsonc](doc/config.sample.jsonc) | @@ -942,6 +965,28 @@ nildiff "/path/to/old-folder" "/path/to/new-folder" "my-comparison" --no-pause 既定の出力先は OS ごとのユーザーローカル app-data フォルダです。設定の詳細は [`doc/config.sample.jsonc`](doc/config.sample.jsonc) を、生成済みレポートの確認は `nildiff --open-reports` を参照してください。 + +### 起動時の更新通知 + +以下の表示条件を満たすコンソールからの直接起動時には、nuget.org に対するベストエフォートの更新確認も行います。確認成功時は最新の安定版を `/update-check.json` に 20 時間キャッシュし、失敗時は 1 時間の再試行バックオフを保存するため、障害中に起動遅延を繰り返しません。新しい版があれば stderr に、`dotnet tool update --global nildiff` を実行する `1. Update now`、今回だけ見送る `2. Skip`、その版を通知対象外にする `3. Skip until next version` を表示します。3 を選ぶと対象版を同じキャッシュに記録し、その版は再表示せず、さらに新しい版が見つかったときだけ再通知します。更新成功時は再起動案内を出して終了し、スキップまたは更新失敗時は要求されたコマンドを続けます。 + +この機能でいう「対話型コンソールからの直接起動」とは、標準入力・標準出力・標準エラーのすべてが**リダイレクトされていない**状態です。Windows のコマンドプロンプト、PowerShell、Windows Terminal 上のシェル、macOS/Linux の一般的なコンソールセッションからの直接実行を含み、「Terminal」という名前のアプリだけを意味するものではありません。更新確認はコマンド振り分けより前に行うため、直接実行した `nildiff --help` や `nildiff --version` も対象です。いずれか 1 つでもリダイレクトまたはパイプされていれば、更新確認フェーズ全体をスキップします。 + +```bash +# 対象: stdin・stdout・stderr をすべてリダイレクトしていない +nildiff --version +nildiff "/path/to/old-folder" "/path/to/new-folder" + +# スキップ: stdout または stderr をリダイレクト +nildiff --version > version.txt +nildiff "/path/to/old-folder" "/path/to/new-folder" 2> errors.txt + +# スキップ: stdin をパイプ +printf 'input\n' | nildiff "/path/to/old-folder" "/path/to/new-folder" +``` + +CI runner、定期実行ジョブ、その他の非対話起動も通常はスキップ対象です。3 ストリームすべてがリダイレクトされていないことを必須にすることで、プロンプトがパイプ入力を消費する、自動処理が入力待ちになる、リダイレクト先に更新文言が混ざる、といった問題を防ぎます。 + ### 方法 B: ソースからクローンしてビルド ```bash @@ -982,6 +1027,7 @@ dotnet run -- "/path/to/old-folder" "/path/to/new-folder" "my-comparison" --no-p | 見たい内容 | ドキュメント | | --- | --- | | 5 分で始める | [USER_GUIDE.md](USER_GUIDE.md#readme-ja-quick-start) | +| 起動時の更新確認とプロンプトを理解する | [起動時の更新通知](#guide-ja-startup-update-notification) | | 製品概要、導入、使い方、設定 | [USER_GUIDE.md](USER_GUIDE.md#readme-ja-usage) | | アセンブリ セマンティック変更の検出 | [USER_GUIDE.md](USER_GUIDE.md#readme-ja-assembly-semantic-changes) | | コメント付き設定サンプル | [doc/config.sample.jsonc](doc/config.sample.jsonc) | diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index b64a2133..5d52aada 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -334,6 +334,7 @@ sequenceDiagram participant CLI as CLI participant Program as Program.cs participant Runner as ProgramRunner + participant Update as UpdateNotificationService participant Config as ConfigService participant Scope as Run Scope participant Diff as FolderDiffService @@ -341,6 +342,8 @@ sequenceDiagram CLI->>Program: Main(args) Program->>Runner: RunAsync(args) + Runner->>Update: check cached/latest stable version + Update-->>Runner: optional three-choice stderr prompt Runner->>Runner: initialize logger and print version Runner->>Runner: validate args and create /