Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.1.8] — 2026-05-27

### Added

- **`UpdateCleanup.Run(...)` startup-cleanup helper.** Startup `CleanupOldInstall()` is synchronous and, under OneDrive / antivirus / Windows Search contention, the `DeleteDirectoryRobustly` retry/backoff path can take several seconds — with no output the app looks hung. New static `UpdateCleanup` helper (mirrors `UpdateBanner`) wraps the cleanup and shows a `Cleaning up previous update…` status spinner **only when there is leftover state to remove**; the common no-leftovers case stays completely silent. `Run(IServiceProvider, IAnsiConsole?)` is the drop-in startup entry point; a `Run(IUpdateInstaller, IAnsiConsole)` overload is provided for explicit/headless callers.
- **`IUpdateInstaller.HasPendingCleanup`.** Cheap, side-effect-free check that returns `true` when a `.old/` or `.update/` directory left by a previous update still exists. Lets a UI layer decide whether to show a cleanup message.

### Changed

- The demo `Program.cs` and the README quick start now call `UpdateCleanup.Run(serviceProvider)` instead of `IUpdateInstaller.CleanupOldInstall()` directly. Purely additive — `CleanupOldInstall()` is unchanged, so existing consumers keep compiling; switching the one startup line opts into the message.

---

## [0.1.7] — 2026-05-25

### Fixed
Expand Down Expand Up @@ -124,6 +137,7 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the
- Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`.
- SourceLink, deterministic builds, published symbol packages.

[0.1.8]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.8
[0.1.7]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.7
[0.1.6]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.6
[0.1.5]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.5
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ services.AddSelfUpdater(opts =>
});

using var sp = services.BuildServiceProvider();
sp.GetRequiredService<IUpdateInstaller>().CleanupOldInstall();
UpdateCleanup.Run(sp); // sweeps .old/.update; shows a message only if there's leftover state
var checkTask = UpdateBanner.KickOffCheck(sp);

var app = new CommandApp(new YourTypeRegistrar(sp));
Expand Down Expand Up @@ -193,7 +193,7 @@ For sources that don't have a channel concept (`HttpManifestSource`), host one m
1. **Acquire lock.** `<install>/.update.lock` opened with `FileShare.None` + `FileOptions.DeleteOnClose`. Concurrent installs lose the race with a clear "another update is in progress" message.
2. **Stage.** Download the asset under `<install>/.update/<tag>/`, run every registered `IPackageVerifier`, extract.
3. **Swap.** Move every entry in `<install>/` (except the maintenance dirs) into `<install>/.old/`. Copy the extracted files into place. Delete `<install>/.update/`.
4. **Cleanup later.** Next startup, `IUpdateInstaller.CleanupOldInstall()` deletes `<install>/.old/` — the running new binary is proof the swap completed.
4. **Cleanup later.** Next startup, `IUpdateInstaller.CleanupOldInstall()` deletes `<install>/.old/` (and any leftover `<install>/.update/`) — the running new binary is proof the swap completed. Call it via `UpdateCleanup.Run(sp)`, which shows a "cleaning up" status message while it works but only when there is leftover state to remove.

This avoids the "EXE is locked while running" problem on Windows without a separate restarter process.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ public static int Main(string[] args)

// 1. Sweep up the previous install (the running new binary is proof
// the last swap completed). Idempotent — safe to call every run.
serviceProvider.GetRequiredService<IUpdateInstaller>().CleanupOldInstall();
// UpdateCleanup shows a status message while it works, but only when
// there's leftover .old/ or .update/ state to remove; otherwise it's
// silent.
UpdateCleanup.Run(serviceProvider);

// 2. Kick off the background "is there a new version?" probe. It's
// short-timeout and read-through-cached; on the warm path it
Expand Down
10 changes: 10 additions & 0 deletions src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ public interface IUpdateInstaller
/// <summary>The directory the running CLI lives in — the swap target.</summary>
string InstallDirectory { get; }

/// <summary>
/// <see langword="true"/> when a startup <see cref="CleanupOldInstall"/>
/// would actually delete something — i.e. a <c>.old/</c> or
/// <c>.update/</c> directory left by a previous update still exists.
/// Lets a UI layer (e.g. <see cref="UpdateCleanup"/>) show a
/// "cleaning up" message only when there is work to do. Cheap
/// directory-existence check with no side effects.
/// </summary>
bool HasPendingCleanup { get; }

/// <summary>
/// Apply the given release. Throws <see cref="UpdateException"/>
/// when any pipeline stage fails; on success returns once all files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.SelfUpdate</PackageId>
<Version>0.1.7</Version>
<Version>0.1.8</Version>
<Authors>Stuart Meeks</Authors>
<Description>Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command.</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ internal UpdateInstaller(

public string InstallDirectory => _installDirResolver();

public bool HasPendingCleanup
{
get
{
var installDir = InstallDirectory;
return Directory.Exists(Path.Combine(installDir, OldDirName))
|| Directory.Exists(Path.Combine(installDir, StagingDirName));
}
}

public async Task InstallAsync(
RemoteRelease release,
IProgress<UpdateProgressEvent>? progress = null,
Expand Down
70 changes: 70 additions & 0 deletions src/NextIteration.SpectreConsole.SelfUpdate/UpdateCleanup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;

using Spectre.Console;

namespace NextIteration.SpectreConsole.SelfUpdate
{
/// <summary>
/// One-call helper for the startup cleanup of a previous update's
/// <c>.old/</c> and <c>.update/</c> leftovers. Call <see cref="Run(IServiceProvider, IAnsiConsole?)"/>
/// at the very start of <c>Main</c> in place of a bare
/// <see cref="IUpdateInstaller.CleanupOldInstall"/> call.
/// <para>
/// The delete is synchronous and — under OneDrive / antivirus / Windows
/// Search contention — can take seconds (see the retry/backoff path in
/// the installer). Without feedback the app looks hung. This helper shows
/// a status message while it works, but <b>only</b> when there is actually
/// something to clean (<see cref="IUpdateInstaller.HasPendingCleanup"/>);
/// the common no-leftovers case stays completely silent.
/// </para>
/// </summary>
public static class UpdateCleanup
{
/// <summary>
/// Status text shown while leftover update state is being removed.
/// </summary>
public const string DefaultMessage = "Cleaning up previous update…";

/// <summary>
/// Resolve <see cref="IUpdateInstaller"/> from the supplied service
/// provider and run the startup cleanup, showing
/// <see cref="DefaultMessage"/> only when leftover state exists.
/// </summary>
/// <param name="services">DI container holding the registered <see cref="IUpdateInstaller"/>.</param>
/// <param name="console">
/// Optional console override. When <see langword="null"/>, an
/// <see cref="IAnsiConsole"/> registered in <paramref name="services"/>
/// is used; failing that, <see cref="AnsiConsole.Console"/>.
/// </param>
public static void Run(IServiceProvider services, IAnsiConsole? console = null)
{
ArgumentNullException.ThrowIfNull(services);
var installer = services.GetRequiredService<IUpdateInstaller>();
Run(installer, console ?? services.GetService<IAnsiConsole>() ?? AnsiConsole.Console);
}

/// <summary>
/// Run the startup cleanup against an explicit installer and console.
/// Shows <see cref="DefaultMessage"/> only when
/// <see cref="IUpdateInstaller.HasPendingCleanup"/> is
/// <see langword="true"/>; otherwise cleans silently.
/// </summary>
/// <param name="installer">The installer to clean up.</param>
/// <param name="console">Console used to render the status message.</param>
public static void Run(IUpdateInstaller installer, IAnsiConsole console)
{
ArgumentNullException.ThrowIfNull(installer);
ArgumentNullException.ThrowIfNull(console);

if (!installer.HasPendingCleanup)
{
installer.CleanupOldInstall(); // nothing to clean — stay silent
return;
}

// The spinner communicates "in progress" while the synchronous,
// retry-backed delete runs, then clears when it returns.
console.Status().Start(DefaultMessage, _ => installer.CleanupOldInstall());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@ internal sealed class StubUpdateInstaller : IUpdateInstaller
{
public string InstallDirectory { get; set; } = "/tmp/install";

public bool HasPendingCleanup { get; set; }

public int CleanupCallCount { get; private set; }

public Task InstallAsync(
RemoteRelease release,
IProgress<UpdateProgressEvent>? progress = null,
Func<UpdateConflict, CancellationToken, Task<UpdateConflictResolution>>? onConflict = null,
CancellationToken ct = default) => Task.CompletedTask;

public void CleanupOldInstall() { }
public void CleanupOldInstall() => CleanupCallCount++;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,42 @@ public void CleanupOldInstall_when_old_directory_missing_is_noop()
installer.CleanupOldInstall(); // does not throw
}

[Fact]
public void HasPendingCleanup_when_neither_directory_exists_false()
{
using var work = new TempDir();
var installDir = Path.Combine(work.Path, "install");
Directory.CreateDirectory(installDir);

var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64");

Assert.False(installer.HasPendingCleanup);
}

[Fact]
public void HasPendingCleanup_when_old_directory_exists_true()
{
using var work = new TempDir();
var installDir = Path.Combine(work.Path, "install");
Directory.CreateDirectory(Path.Combine(installDir, ".old"));

var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64");

Assert.True(installer.HasPendingCleanup);
}

[Fact]
public void HasPendingCleanup_when_update_directory_exists_true()
{
using var work = new TempDir();
var installDir = Path.Combine(work.Path, "install");
Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2"));

var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64");

Assert.True(installer.HasPendingCleanup);
}

[Fact]
public async Task InstallAsync_when_lock_file_held_throws()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using Microsoft.Extensions.DependencyInjection;

using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure;

using Spectre.Console;
using Spectre.Console.Testing;

using Xunit;

namespace NextIteration.SpectreConsole.SelfUpdate.Tests
{
public sealed class UpdateCleanupTests
{
[Fact]
public void Run_when_no_pending_cleanup_writes_nothing_and_cleans()
{
var installer = new StubUpdateInstaller { HasPendingCleanup = false };
var console = new TestConsole();

UpdateCleanup.Run(installer, console);

Assert.Equal(1, installer.CleanupCallCount);
Assert.Equal(string.Empty, console.Output);
}

[Fact]
public void Run_when_pending_cleanup_renders_message_and_cleans()
{
var installer = new StubUpdateInstaller { HasPendingCleanup = true };
var console = new TestConsole();

UpdateCleanup.Run(installer, console);

Assert.Equal(1, installer.CleanupCallCount);
Assert.Contains("Cleaning up previous update", console.Output, StringComparison.Ordinal);
}

[Fact]
public void Run_via_service_provider_resolves_installer_and_console()
{
var installer = new StubUpdateInstaller { HasPendingCleanup = true };
var console = new TestConsole();
var services = new ServiceCollection();
services.AddSingleton<IUpdateInstaller>(installer);
services.AddSingleton<IAnsiConsole>(console);
using var sp = services.BuildServiceProvider();

UpdateCleanup.Run(sp);

Assert.Equal(1, installer.CleanupCallCount);
Assert.Contains("Cleaning up previous update", console.Output, StringComparison.Ordinal);
}

[Fact]
public void Run_with_null_services_throws()
{
Assert.Throws<ArgumentNullException>(() => UpdateCleanup.Run((IServiceProvider)null!));
}

[Fact]
public void Run_with_null_installer_throws()
{
Assert.Throws<ArgumentNullException>(() => UpdateCleanup.Run((IUpdateInstaller)null!, new TestConsole()));
}

[Fact]
public void Run_with_null_console_throws()
{
Assert.Throws<ArgumentNullException>(() => UpdateCleanup.Run(new StubUpdateInstaller(), null!));
}
}
}
Loading