diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26dbddf..e15ef8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,22 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
-## [Unreleased]
+## [0.1.3] — 2026-05-03
-### Symbol packaging (planned 0.1.2)
-- **`portable`** instead of `embedded`. The previous combination produced an empty `.snupkg` (no `.pdb` files because debug info was inside the `.dll`), which nuget.org rejects with HTTP 400. The published `.nupkg` is now slightly smaller, and the `.snupkg` actually contains symbols so consumers debugging into the library get sources via nuget.org's symbol server.
+### Added
+
+- **`SelfUpdaterOptions.PreservePaths`.** Glob list (`appsettings.Development.json`, `appsettings.*.json`, `data/**`, `*.db`, …) telling the installer which top-level entries in the install directory belong to the user, not the package. Matched entries are skipped in Phase 1 (not moved into `.old/`) and don't get clobbered by a new release in Phase 2. Defaults to empty — current consumers get unchanged behaviour until they opt in.
+- **Per-conflict resolver.** When a new release ships an entry whose path matches a `PreservePaths` pattern, `ISelfUpdater.InstallAsync` and `IUpdateInstaller.InstallAsync` accept an optional `Func>?` resolver. `null` (default) keeps the user's file. Headless callers can return a constant; interactive callers can prompt per file. New `UpdateConflict` record carries `RelativePath`, `ExistingSizeBytes`, `NewSizeBytes`.
+- **`update --strategy ask|keep|new`.** New flag on `UpdateCommand`. With `--yes`, defaults to `keep` so updates never block on a prompt. Without `--yes`, defaults to `ask` and uses Spectre's `Confirm` per conflict.
+
+### Changed
+
+- Layered config support is documented in the README: end-user CLIs can read additional `PreservePaths` entries from `appsettings*.json` via `IConfiguration` and merge with the in-code list — no new package API needed.
+
+---
+
+## [0.1.2] — 2026-05-03
+
+### Fixed
+
+- **Symbol package now actually contains symbols.** The previous combo (`true` + `snupkg` + `embedded`) produced an empty `.snupkg` because debug info was embedded inside the `.dll`; nuget.org rejects empty symbol packages with HTTP 400. v0.1.1's `.nupkg` was published successfully but the symbol upload failed. Switching to `portable` produces a real `.pdb` next to the `.dll`; the `.snupkg` now ships it; nuget.org accepts the symbol upload; consumers debugging into the library get sources via the nuget.org symbol server. Same fix landed across all four sibling repos (Splash 0.1.2, Auth 0.6.2, Auth.Providers 0.2.2 / 0.2.2 / 0.3.2).
+
+---
+
+## [0.1.1] — 2026-05-03
+
+Coordinated patch driven by an external code review.
+
+### Security
-### Security & correctness fixes (planned 0.1.1)
- **Lock before staging mutation.** `UpdateInstaller.InstallAsync` now acquires `.update.lock` before any change to `.update//`. Previously a second installer could wipe a first installer's in-flight staging directory on its way to losing the lock race.
-- **Rollback on swap failure.** A copy failure mid-swap now restores the install directory from `.old/` instead of leaving it half-populated. New `UpdateInstaller.RestoreFromOld` helper.
- **Asset-name validation.** New `UpdateInstaller.ValidateAssetName` rejects path separators, parent references, rooted paths, and any name whose `Path.GetFileName` doesn't round-trip — closing a path-traversal vector for malicious or misconfigured sources.
-- **HTTPS enforcement in `HttpManifestSource`.** Plain-HTTP manifest URLs and asset URLs are now rejected by default. Opt in via `SelfUpdaterOptions.AllowInsecureManifestSource = true` (tests, internal mirrors, local dev). Plain HTTP defeats SHA-256 verification because the SHA itself is MITM-able.
+- **HTTPS enforcement in `HttpManifestSource`.** Plain-HTTP manifest URLs and asset URLs are now rejected by default. Opt in via `SelfUpdaterOptions.AllowInsecureManifestSource = true` for tests, internal mirrors on a trusted network, and local development. Plain HTTP defeats SHA-256 verification because the SHA itself is MITM-able.
+
+### Fixed
+
+- **Rollback on swap failure.** A copy failure mid-swap now restores the install directory from `.old/` instead of leaving it half-populated. New `UpdateInstaller.RestoreFromOld` helper.
- **TOCTOU-safe install path.** `UpdateCommand` now fetches the release once and installs that exact instance — no second source query between display and install. New `ISelfUpdater.GetLatestReleaseAsync()` and `ISelfUpdater.InstallAsync(RemoteRelease, ...)` overloads. The parameterless `InstallAsync` is kept as a convenience for non-interactive consumers (TOCTOU window documented).
-### Build hygiene
+### Changed
+
- `` is now Release-only; ordinary `dotnet build` and `dotnet test` no longer produce `.nupkg` files. Output path moved from `C:\nuget-local\` to `$(MSBuildThisFileDirectory)..\..\artifacts\packages` — platform-neutral, repo-local, and gitignored.
+- Test-suite count: 86 → 150.
+
+---
+
+## [0.1.0] — 2026-05-03
+
+Initial commit. Never published to nuget.org — superseded by 0.1.1 before the first tag was cut.
+
+### Added — initial public release
-### Added — initial public release (planned 0.1.0)
- **Pluggable update sources** — `IUpdateSource` contract with three built-in implementations:
- `HttpGitHubReleaseSource` (default) — public GitHub Releases via HttpClient.
- `GhCliReleaseSource` — private GitHub repos via the `gh` CLI.
@@ -36,4 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Configurable cache, timeouts, opt-out env var, dev-build skip predicate.**
- DI wiring via `ServiceCollectionExtensions.AddSelfUpdater(...)`.
- Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`.
-- SourceLink, deterministic builds, embedded symbols, published symbol packages.
+- SourceLink, deterministic builds, published symbol packages.
+
+[0.1.3]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.3
+[0.1.2]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.2
+[0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.1
diff --git a/README.md b/README.md
index 73fe553..78e51e4 100644
--- a/README.md
+++ b/README.md
@@ -199,6 +199,98 @@ This avoids the "EXE is locked while running" problem on Windows without a separ
---
+## Preserving user files across updates
+
+By default the installer treats every entry in the install directory as
+package-owned: anything that's not in the new release ends up in `.old/`
+and gets cleaned up on next startup. That's wrong for files the *user*
+placed there — `appsettings.Development.json`, a local SQLite database,
+a `data/` folder, scratch notes, plugins.
+
+`SelfUpdaterOptions.PreservePaths` is a list of glob patterns identifying
+top-level entries the installer should leave alone:
+
+```csharp
+services.AddSelfUpdater(opts =>
+{
+ opts.AppName = "myapp";
+ opts.UseGitHubReleases("acme/myapp");
+
+ opts.PreservePaths = new[]
+ {
+ "appsettings.Development.json", // exact filename
+ "appsettings.*.json", // glob over top-level files
+ "data/**", // whole top-level directory
+ "*.db", // sqlite or similar
+ };
+});
+```
+
+Patterns match the **top-level entry name** (the part before the first
+`/` in the pattern). `data/**` and `data/seed.json` both preserve the
+whole `data/` directory; nested-only preservation isn't supported in
+v0.1.x.
+
+### Letting end users extend the list via `appsettings.json`
+
+The `PreservePaths` property is a plain `IReadOnlyList`, so
+consumers can populate it from `IConfiguration` and merge with their
+in-code defaults:
+
+```csharp
+var fromConfig = configuration
+ .GetSection("SelfUpdate:Preserve")
+ .Get() ?? Array.Empty();
+
+services.AddSelfUpdater(opts =>
+{
+ opts.AppName = "myapp";
+ opts.UseGitHubReleases("acme/myapp");
+ opts.PreservePaths = new[]
+ {
+ "appsettings.Development.json",
+ "data/**",
+ }.Concat(fromConfig).ToArray();
+});
+```
+
+End users can then drop their own paths into `appsettings.json` (or
+`appsettings.Development.json` next to the binary) without recompiling
+the CLI:
+
+```json
+{
+ "SelfUpdate": {
+ "Preserve": [ "my-custom-plugins/**", "notes.md" ]
+ }
+}
+```
+
+### Conflict resolution when a release ships a preserved path
+
+If a new release publishes an entry whose path matches one of the
+preserve patterns, the installer asks the resolver passed to
+`InstallAsync` what to do:
+
+```csharp
+await selfUpdater.InstallAsync(
+ release,
+ progress: null,
+ onConflict: (conflict, ct) =>
+ {
+ // conflict.RelativePath, conflict.ExistingSizeBytes, conflict.NewSizeBytes
+ return Task.FromResult(UpdateConflictResolution.KeepExisting);
+ });
+```
+
+Default (no resolver passed) is `KeepExisting` — the user's file wins.
+The drop-in `update` command exposes a `--strategy ask|keep|new` flag:
+with `--yes` it defaults to `keep` (so unattended runs never block on a
+prompt); without `--yes` it defaults to `ask` and prompts the user
+per file via Spectre's `Confirm`.
+
+---
+
## Configuration reference
| Property | Default | Description |
@@ -214,6 +306,8 @@ This avoids the "EXE is locked while running" problem on Windows without a separ
| `SkipVersionPredicate` | `v => v == "1.0.0"` | Returns true to suppress the check (for unstamped dev builds). |
| `GitHubToken` | `null` (then `GITHUB_TOKEN`/`GH_TOKEN` env) | Optional bearer token for `HttpGitHubReleaseSource`. |
| `UseDefaultSha256Verifier` | `true` | Whether to register the SHA-256 verifier automatically. |
+| `AllowInsecureManifestSource` | `false` | When true, `HttpManifestSource` accepts `http://` URLs. Tests / internal mirrors only — plain HTTP defeats SHA-256 verification. |
+| `PreservePaths` | `[]` | Glob patterns identifying top-level entries the installer must leave alone (`appsettings.Development.json`, `data/**`, `*.db`, …). See [Preserving user files](#preserving-user-files-across-updates). |
---
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
index e4f0cfa..ebafe23 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
@@ -31,8 +31,21 @@ public sealed class Settings : CommandSettings
[CommandOption("--force")]
[Description("Reinstall even if already on the latest version.")]
public bool Force { get; init; }
+
+ ///
+ /// How to resolve conflicts where a new release ships an entry whose
+ /// path matches one of SelfUpdaterOptions.PreservePaths:
+ /// ask prompts per file, keep always keeps the user's,
+ /// new always uses the new release's. Default ask when
+ /// running interactively (no --yes), keep with --yes.
+ ///
+ [CommandOption("--strategy")]
+ [Description("Conflict strategy when a release ships a preserved path: ask | keep | new.")]
+ public string? Strategy { get; init; }
}
+ private enum ConflictStrategy { Ask, Keep, New }
+
private readonly ISelfUpdater _selfUpdater;
private readonly IUpdateChecker _checker;
private readonly IUpdateInstaller _installer;
@@ -99,13 +112,16 @@ protected override async Task ExecuteAsync(CommandContext context, Settings
return 2;
}
+ var strategy = ResolveStrategy(settings);
+ var conflictResolver = BuildConflictResolver(strategy);
+
try
{
await _console.Status().StartAsync("Updating…", async statusContext =>
{
var progress = new Progress(evt =>
statusContext.Status(StageLabel(evt.Stage)));
- await _selfUpdater.InstallAsync(release, progress, cancellationToken).ConfigureAwait(false);
+ await _selfUpdater.InstallAsync(release, progress, conflictResolver, cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
}
catch (UpdateException ex)
@@ -126,6 +142,47 @@ private bool PromptToContinue(string tag)
defaultValue: true);
}
+ private static ConflictStrategy ResolveStrategy(Settings settings)
+ {
+ if (string.IsNullOrWhiteSpace(settings.Strategy))
+ {
+ // Non-interactive runs (--yes) default to keep so updates
+ // never block on a prompt. Interactive runs default to ask
+ // so the user gets a real chance to make a decision.
+ return settings.Yes ? ConflictStrategy.Keep : ConflictStrategy.Ask;
+ }
+ return settings.Strategy.ToLowerInvariant() switch
+ {
+ "ask" => ConflictStrategy.Ask,
+ "keep" => ConflictStrategy.Keep,
+ "new" => ConflictStrategy.New,
+ _ => throw new InvalidOperationException(
+ $"Unknown --strategy value '{settings.Strategy}'. Expected one of: ask, keep, new."),
+ };
+ }
+
+ private Func>? BuildConflictResolver(ConflictStrategy strategy) =>
+ strategy switch
+ {
+ ConflictStrategy.Keep => null, // null is the documented "KeepExisting" default
+ ConflictStrategy.New => (_, _) => Task.FromResult(UpdateConflictResolution.UseNew),
+ ConflictStrategy.Ask => PromptForConflictAsync,
+ _ => null,
+ };
+
+ private Task PromptForConflictAsync(UpdateConflict conflict, CancellationToken ct)
+ {
+ _console.MarkupLineInterpolated(CultureInfo.InvariantCulture,
+ $"[yellow]Preserved path conflict:[/] [bold]{Markup.Escape(conflict.RelativePath)}[/]");
+ if (conflict.ExistingSizeBytes is { } ex && conflict.NewSizeBytes is { } nu)
+ {
+ _console.MarkupLineInterpolated(CultureInfo.InvariantCulture,
+ $" yours: {ex} bytes → new: {nu} bytes");
+ }
+ var keep = _console.Confirm("Keep your existing file?", defaultValue: true);
+ return Task.FromResult(keep ? UpdateConflictResolution.KeepExisting : UpdateConflictResolution.UseNew);
+ }
+
private static bool IsUpdateAvailable(string current, string latestTag)
{
// Defer to the same comparator the checker uses so behaviour is
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
index 4a101cf..5140bb3 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
@@ -19,7 +19,7 @@ public interface ISelfUpdater
/// latest release on the configured channel. Returns
/// when nothing is available. Use this when
/// you want to display "what would be installed?" to the user
- /// before calling
+ /// before calling
/// — the same release instance can be passed to install so the
/// displayed and installed versions are guaranteed to match (no
/// TOCTOU window between display and install).
@@ -34,9 +34,18 @@ public interface ISelfUpdater
/// user a specific release — passing it back avoids the second
/// source query the parameterless overload performs.
///
+ /// The release to install.
+ /// Optional progress sink for stage-level events.
+ ///
+ /// Optional resolver invoked when a new release entry lands on a
+ /// path covered by .
+ /// (default) keeps the user's existing file.
+ ///
+ /// Cancellation token.
Task InstallAsync(
RemoteRelease release,
IProgress? progress = null,
+ Func>? onConflict = null,
CancellationToken ct = default);
///
@@ -47,7 +56,7 @@ Task InstallAsync(
/// release returned by the source here may differ from the one a
/// prior reported. For interactive UIs,
/// prefer the +
- ///
+ ///
/// pair so the user confirms exactly the release that gets
/// installed.
///
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs
index 382f079..490985c 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs
@@ -21,10 +21,21 @@ public interface IUpdateInstaller
///
/// The resolved release to install.
/// Optional progress sink for stage-level events.
+ ///
+ /// Optional resolver invoked when a new release entry lands on a
+ /// path covered by .
+ /// (default) means
+ /// — the user's
+ /// file is left in place and the new release's copy is discarded
+ /// for that path. Resolvers are called once per conflicting entry,
+ /// in deterministic order, and may return different decisions per
+ /// entry.
+ ///
/// Cancellation token.
Task InstallAsync(
RemoteRelease release,
IProgress? progress = null,
+ Func>? onConflict = null,
CancellationToken ct = default);
///
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
index a8fd752..60967fa 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
@@ -11,7 +11,7 @@
NextIteration.SpectreConsole.SelfUpdate
- 0.1.2
+ 0.1.3
Stuart Meeks
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.
true
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
index c7a2d4a..f4aa2c3 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
@@ -36,10 +36,14 @@ public SelfUpdater(
public Task GetLatestReleaseAsync(CancellationToken ct = default) =>
_source.GetLatestAsync(_options.Channel, ct);
- public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default)
+ public Task InstallAsync(
+ RemoteRelease release,
+ IProgress? progress = null,
+ Func>? onConflict = null,
+ CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(release);
- return _installer.InstallAsync(release, progress, ct);
+ return _installer.InstallAsync(release, progress, onConflict, ct);
}
public async Task InstallAsync(IProgress? progress = null, CancellationToken ct = default)
@@ -50,7 +54,7 @@ public async Task InstallAsync(IProgress? progress = null,
throw new UpdateException(
"No release is available from the configured update source. The source either returned null or is currently unreachable.");
}
- await _installer.InstallAsync(release, progress, ct).ConfigureAwait(false);
+ await _installer.InstallAsync(release, progress, onConflict: null, ct).ConfigureAwait(false);
}
}
}
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs
index 0bf2a63..d6dc24e 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs
@@ -58,7 +58,11 @@ internal UpdateInstaller(
public string InstallDirectory => _installDirResolver();
- public async Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default)
+ public async Task InstallAsync(
+ RemoteRelease release,
+ IProgress? progress = null,
+ Func>? onConflict = null,
+ CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(release);
@@ -111,7 +115,7 @@ public async Task InstallAsync(RemoteRelease release, IProgress
+ SwapAsync(sourceDirectory, installDirectory, oldDirectory, Array.Empty(), onConflict: null, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ internal static async Task SwapAsync(
+ string sourceDirectory,
+ string installDirectory,
+ string oldDirectory,
+ IReadOnlyList preservePaths,
+ Func>? onConflict,
+ CancellationToken ct)
{
// Reset .old/ — it should be empty going in.
if (Directory.Exists(oldDirectory))
@@ -178,9 +195,10 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin
}
Directory.CreateDirectory(oldDirectory);
- // Phase 1: move install/ contents (except maintenance) → .old/.
- // Track what we moved so a failure mid-loop can restore the
- // partial move and leave the install dir as we found it.
+ // Phase 1: move install/ contents (except maintenance and
+ // preserved entries) → .old/. Track what we moved so a failure
+ // mid-loop can restore the partial move and leave the install
+ // dir as we found it.
var movedNames = new List();
try
{
@@ -188,6 +206,7 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin
{
var name = Path.GetFileName(entry);
if (IsMaintenanceEntry(name)) continue;
+ if (IsPreserved(name, preservePaths)) continue;
var dest = Path.Combine(oldDirectory, name);
if (File.Exists(entry))
@@ -207,16 +226,48 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin
throw;
}
- // Phase 2: copy new entries in. Track each placed entry so a
- // failure mid-copy can rip out the partial replacement and
- // restore the previous install from .old/.
+ // Phase 2: copy new entries in. When a new entry lands on a
+ // preserved path, ask the resolver (or default to KeepExisting).
+ // Track each placed entry so a failure mid-copy can rip out
+ // the partial replacement and restore the previous install
+ // from .old/.
var copiedNames = new List();
try
{
foreach (var entry in Directory.EnumerateFileSystemEntries(sourceDirectory))
{
+ ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(entry);
var dest = Path.Combine(installDirectory, name);
+
+ // Conflict path: source ships an entry whose top-level
+ // name matches a preserved pattern.
+ if (IsPreserved(name, preservePaths))
+ {
+ var existingExists = File.Exists(dest) || Directory.Exists(dest);
+ if (existingExists)
+ {
+ var resolution = await ResolveConflictAsync(name, dest, entry, onConflict, ct).ConfigureAwait(false);
+ if (resolution == UpdateConflictResolution.KeepExisting)
+ {
+ // User's file wins — drop the new entry on the floor.
+ continue;
+ }
+ // UseNew: the existing preserved entry wasn't
+ // moved in Phase 1; do it inline now so the
+ // copy below has somewhere clean to land and
+ // rollback restores the right thing on failure.
+ var oldDest = Path.Combine(oldDirectory, name);
+ TryDeleteEntry(oldDest);
+ if (File.Exists(dest)) File.Move(dest, oldDest);
+ else if (Directory.Exists(dest)) Directory.Move(dest, oldDest);
+ movedNames.Add(name);
+ }
+ // else: a new release introduces a path that the
+ // user marked as preservable but doesn't exist
+ // locally. Treat as a normal copy.
+ }
+
if (File.Exists(entry))
{
File.Copy(entry, dest, overwrite: true);
@@ -239,6 +290,55 @@ internal static void Swap(string sourceDirectory, string installDirectory, strin
}
}
+ private static async Task ResolveConflictAsync(
+ string name,
+ string existingPath,
+ string newPath,
+ Func>? onConflict,
+ CancellationToken ct)
+ {
+ if (onConflict is null)
+ {
+ return UpdateConflictResolution.KeepExisting;
+ }
+
+ long? existingSize = File.Exists(existingPath) ? new FileInfo(existingPath).Length : null;
+ long? newSize = File.Exists(newPath) ? new FileInfo(newPath).Length : null;
+ var conflict = new UpdateConflict(name.Replace(Path.DirectorySeparatorChar, '/'), existingSize, newSize);
+ return await onConflict(conflict, ct).ConfigureAwait(false);
+ }
+
+ internal static bool IsPreserved(string name, IReadOnlyList preservePaths)
+ {
+ if (preservePaths.Count == 0) return false;
+ foreach (var pattern in preservePaths)
+ {
+ if (string.IsNullOrWhiteSpace(pattern)) continue;
+ // Take the part of the pattern before the first slash —
+ // this lets `data/**`, `data/seed.json`, and bare `data`
+ // all match the top-level entry `data`. Nested-only
+ // preservation (e.g. preserve only `data/seed.json` but
+ // not the rest of `data/`) is out of scope for v0.1.x.
+ var head = TopLevelSegment(pattern);
+ if (head.Length == 0) continue;
+ if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static ReadOnlySpan TopLevelSegment(string pattern)
+ {
+ ReadOnlySpan span = pattern;
+ for (var i = 0; i < span.Length; i++)
+ {
+ if (span[i] == '/' || span[i] == '\\') return span[..i];
+ }
+ return span;
+ }
+
// Move every named entry from .old/ back into install/, replacing
// anything currently at the destination. Best-effort — any single
// entry that can't be restored is logged silently; the original
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
index efbe224..d451adf 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
@@ -115,6 +115,31 @@ public sealed class SelfUpdaterOptions
///
public bool AllowInsecureManifestSource { get; set; }
+ ///
+ /// Glob patterns identifying files and directories the installer
+ /// should leave alone during an update. Patterns are matched
+ /// against the path relative to the install directory using
+ /// forward-slash separators, e.g.
+ /// "appsettings.Development.json", "appsettings.*.json",
+ /// "data/**". ** matches any number of path segments;
+ /// * matches any run of non-separator characters.
+ ///
+ /// Matched entries are skipped in both phases of the swap:
+ /// they are not moved into .old/, and a new release entry
+ /// landing on a preserved path is resolved by the conflict
+ /// resolver passed to
+ /// (default: keep the existing user file).
+ ///
+ ///
+ /// Defaults to empty — the installer treats every entry in the
+ /// install directory as package-owned. Add patterns for any user-
+ /// editable config (appsettings.Development.json),
+ /// per-machine state (*.db, logs/**), or anything
+ /// the consumer wouldn't want to lose across an upgrade.
+ ///
+ ///
+ public IReadOnlyList PreservePaths { get; set; } = Array.Empty();
+
// ---------- Source registration ----------
///
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs
new file mode 100644
index 0000000..caa6f53
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflict.cs
@@ -0,0 +1,28 @@
+namespace NextIteration.SpectreConsole.SelfUpdate
+{
+ ///
+ /// Describes a single file conflict surfaced by the installer to a
+ /// caller-supplied resolver: a new release ships an entry whose path
+ /// matches one of , so
+ /// the installer needs to know whether to keep the user's local copy
+ /// or overwrite it with the new release's version.
+ ///
+ ///
+ /// Path of the conflicting entry, relative to the install directory
+ /// (e.g. "appsettings.Development.json" or "data/seed.json").
+ /// Forward-slash separated regardless of OS so resolvers can match
+ /// against patterns portably.
+ ///
+ ///
+ /// Size of the user's existing file in bytes, or
+ /// when the existing entry is a directory.
+ ///
+ ///
+ /// Size of the new release's file in bytes, or
+ /// when the new entry is a directory.
+ ///
+ public sealed record UpdateConflict(
+ string RelativePath,
+ long? ExistingSizeBytes,
+ long? NewSizeBytes);
+}
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs
new file mode 100644
index 0000000..d4da906
--- /dev/null
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateConflictResolution.cs
@@ -0,0 +1,24 @@
+namespace NextIteration.SpectreConsole.SelfUpdate
+{
+ ///
+ /// Per-file decision returned by a conflict resolver when a new release
+ /// ships an entry whose path matches one of
+ /// .
+ ///
+ public enum UpdateConflictResolution
+ {
+ ///
+ /// Keep the user's existing file untouched. The new release's copy
+ /// is silently discarded for this path. Safe default — chosen when
+ /// no resolver is supplied.
+ ///
+ KeepExisting,
+
+ ///
+ /// Replace the user's existing file with the new release's copy.
+ /// The previous content is moved to .old/ as part of the
+ /// normal swap.
+ ///
+ UseNew,
+ }
+}
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
index e29f109..db86a45 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
@@ -92,7 +92,7 @@ public async Task Execute_when_install_succeeds_returns_zero_and_calls_install()
configUpdater: u =>
{
u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
- u.InstallReleaseImpl = (_, _, _) =>
+ u.InstallReleaseImpl = (_, _, _, _) =>
{
installCalls++;
return Task.CompletedTask;
@@ -118,7 +118,7 @@ public async Task Execute_passes_resolved_release_to_install()
configUpdater: u =>
{
u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
- u.InstallReleaseImpl = (release, _, _) =>
+ u.InstallReleaseImpl = (release, _, _, _) =>
{
receivedRelease = release;
return Task.CompletedTask;
@@ -148,7 +148,7 @@ public async Task Execute_does_not_call_parameterless_install()
parameterlessCalls++;
return Task.CompletedTask;
};
- u.InstallReleaseImpl = (_, _, _) => Task.CompletedTask;
+ u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask;
});
await run("--yes");
@@ -164,7 +164,7 @@ public async Task Execute_when_install_throws_UpdateException_returns_three()
configUpdater: u =>
{
u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
- u.InstallReleaseImpl = (_, _, _) => throw new UpdateException("boom");
+ u.InstallReleaseImpl = (_, _, _, _) => throw new UpdateException("boom");
});
var exit = await run("--yes");
@@ -183,7 +183,7 @@ public async Task Execute_when_force_and_up_to_date_proceeds_to_install()
configUpdater: u =>
{
u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
- u.InstallReleaseImpl = (_, _, _) =>
+ u.InstallReleaseImpl = (_, _, _, _) =>
{
installCalls++;
return Task.CompletedTask;
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
index fee142f..dbbe6db 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
@@ -6,7 +6,7 @@ internal sealed class StubSelfUpdater : ISelfUpdater
public Func>? CheckImpl { get; set; }
public Func>? GetLatestImpl { get; set; }
public Func?, CancellationToken, Task>? InstallImpl { get; set; }
- public Func?, CancellationToken, Task>? InstallReleaseImpl { get; set; }
+ public Func?, Func>?, CancellationToken, Task>? InstallReleaseImpl { get; set; }
public Task CheckAsync(CancellationToken ct = default) =>
CheckImpl?.Invoke(ct) ?? Task.FromResult(null);
@@ -14,8 +14,12 @@ internal sealed class StubSelfUpdater : ISelfUpdater
public Task GetLatestReleaseAsync(CancellationToken ct = default) =>
GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null);
- public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) =>
- InstallReleaseImpl?.Invoke(release, progress, ct) ?? Task.CompletedTask;
+ public Task InstallAsync(
+ RemoteRelease release,
+ IProgress? progress = null,
+ Func>? onConflict = null,
+ CancellationToken ct = default) =>
+ InstallReleaseImpl?.Invoke(release, progress, onConflict, ct) ?? Task.CompletedTask;
public Task InstallAsync(IProgress? progress = null, CancellationToken ct = default) =>
InstallImpl?.Invoke(progress, ct) ?? Task.CompletedTask;
@@ -38,8 +42,11 @@ internal sealed class StubUpdateInstaller : IUpdateInstaller
{
public string InstallDirectory { get; set; } = "/tmp/install";
- public Task InstallAsync(RemoteRelease release, IProgress? progress = null, CancellationToken ct = default) =>
- Task.CompletedTask;
+ public Task InstallAsync(
+ RemoteRelease release,
+ IProgress? progress = null,
+ Func>? onConflict = null,
+ CancellationToken ct = default) => Task.CompletedTask;
public void CleanupOldInstall() { }
}
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs
index f492c95..b8f2aca 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs
@@ -12,6 +12,10 @@ public sealed class UpdateInstallerTests
{
private static readonly string[] TwoEntryNames = { "a.txt", "subdir" };
private static readonly string[] OneEntryName = { "a.txt" };
+ private static readonly string[] PreserveAppsettingsDevelopment = { "appsettings.Development.json" };
+ private static readonly string[] PreserveAppsettingsJson = { "appsettings.json" };
+ private static readonly string[] PreserveDb = { "*.db" };
+ private static readonly string[] PreserveAppsettingsGlob = { "appsettings.*.json" };
[Fact]
public async Task InstallAsync_swaps_files_into_install_directory()
@@ -36,7 +40,7 @@ public async Task InstallAsync_swaps_files_into_install_directory()
};
var installer = NewInstaller(installDir, source, "linux-x64");
- await installer.InstallAsync(release, progress: null, CancellationToken.None);
+ await installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None);
// New files in place
Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Combine(installDir, "myapp.exe")));
@@ -70,7 +74,7 @@ public async Task InstallAsync_throws_when_no_asset_matches_rid()
var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-arm64");
var ex = await Assert.ThrowsAsync(() =>
- installer.InstallAsync(release, progress: null, CancellationToken.None));
+ installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None));
Assert.Contains("No release asset matches RID 'linux-arm64'", ex.Message, StringComparison.Ordinal);
}
@@ -91,7 +95,7 @@ public async Task InstallAsync_reports_progress_for_each_stage()
var stages = new List();
var progress = new Progress(e => stages.Add(e.Stage));
var installer = NewInstaller(installDir, source, "linux-x64");
- await installer.InstallAsync(release, progress, CancellationToken.None);
+ await installer.InstallAsync(release, progress, onConflict: null, CancellationToken.None);
// Allow Progress to drain.
await Task.Yield();
@@ -146,7 +150,7 @@ public async Task InstallAsync_when_lock_file_held_throws()
var installer = NewInstaller(installDir, source, "linux-x64");
await Assert.ThrowsAsync(() =>
- installer.InstallAsync(release, progress: null, CancellationToken.None));
+ installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None));
}
[Fact]
@@ -177,7 +181,7 @@ public async Task InstallAsync_when_lock_held_leaves_existing_staging_intact()
var installer = NewInstaller(installDir, source, "linux-x64");
await Assert.ThrowsAsync(() =>
- installer.InstallAsync(release, progress: null, CancellationToken.None));
+ installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None));
Assert.True(File.Exists(sentinel),
"Lock acquisition must precede any staging mutation.");
@@ -215,7 +219,7 @@ public async Task InstallAsync_when_resolver_returns_malicious_name_throws()
installDirResolver: () => installDir);
var ex = await Assert.ThrowsAsync(() =>
- installer.InstallAsync(release, progress: null, CancellationToken.None));
+ installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None));
Assert.Contains("Refusing to install asset", ex.Message, StringComparison.Ordinal);
}
@@ -334,6 +338,157 @@ public void Swap_when_phase2_copy_fails_restores_install_from_old()
Assert.False(File.Exists(Path.Combine(installDir, "newfile.txt")));
}
+ [Theory]
+ [InlineData("appsettings.Development.json", "appsettings.Development.json", true)]
+ [InlineData("appsettings.*.json", "appsettings.Development.json", true)]
+ [InlineData("appsettings.*.json", "appsettings.json", false)]
+ [InlineData("data/**", "data", true)]
+ [InlineData("data/**", "other", false)]
+ [InlineData("data/seed.json", "data", true)] // top-level rule: pattern's first segment matches dir name
+ [InlineData("*.db", "myapp.db", true)]
+ [InlineData("*.db", "config.json", false)]
+ [InlineData("", "anything", false)] // empty pattern is ignored
+ public void IsPreserved_matches_top_level_entry_against_pattern(string pattern, string name, bool expected)
+ {
+ var patterns = new[] { pattern };
+ Assert.Equal(expected, UpdateInstaller.IsPreserved(name, patterns));
+ }
+
+ [Fact]
+ public void IsPreserved_with_empty_list_returns_false()
+ {
+ Assert.False(UpdateInstaller.IsPreserved("anything.txt", Array.Empty()));
+ }
+
+ [Fact]
+ public async Task SwapAsync_preserved_entry_is_not_moved_to_old()
+ {
+ using var work = new TempDir();
+ var installDir = work.Combine("install");
+ var sourceDir = work.Combine("src");
+ var oldDir = Path.Combine(installDir, ".old");
+ Directory.CreateDirectory(installDir);
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(installDir, "appsettings.Development.json"), "user-config");
+ File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary");
+ File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary");
+
+ await UpdateInstaller.SwapAsync(
+ sourceDir, installDir, oldDir,
+ preservePaths: PreserveAppsettingsDevelopment,
+ onConflict: null,
+ CancellationToken.None);
+
+ // Preserved file untouched.
+ Assert.Equal("user-config", File.ReadAllText(Path.Combine(installDir, "appsettings.Development.json")));
+ Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.Development.json")));
+ // Non-preserved file replaced.
+ Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe")));
+ Assert.Equal("old-binary", File.ReadAllText(Path.Combine(oldDir, "binary.exe")));
+ }
+
+ [Fact]
+ public async Task SwapAsync_when_release_conflicts_default_keeps_existing()
+ {
+ using var work = new TempDir();
+ var installDir = work.Combine("install");
+ var sourceDir = work.Combine("src");
+ var oldDir = Path.Combine(installDir, ".old");
+ Directory.CreateDirectory(installDir);
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited");
+ File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default");
+
+ await UpdateInstaller.SwapAsync(
+ sourceDir, installDir, oldDir,
+ preservePaths: PreserveAppsettingsJson,
+ onConflict: null, // null → keep existing
+ CancellationToken.None);
+
+ Assert.Equal("user-edited", File.ReadAllText(Path.Combine(installDir, "appsettings.json")));
+ Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.json")));
+ }
+
+ [Fact]
+ public async Task SwapAsync_when_resolver_returns_use_new_replaces_existing()
+ {
+ using var work = new TempDir();
+ var installDir = work.Combine("install");
+ var sourceDir = work.Combine("src");
+ var oldDir = Path.Combine(installDir, ".old");
+ Directory.CreateDirectory(installDir);
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited");
+ File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default");
+
+ UpdateConflict? sawConflict = null;
+ Func> resolver =
+ (c, _) => { sawConflict = c; return Task.FromResult(UpdateConflictResolution.UseNew); };
+
+ await UpdateInstaller.SwapAsync(
+ sourceDir, installDir, oldDir,
+ preservePaths: PreserveAppsettingsJson,
+ onConflict: resolver,
+ CancellationToken.None);
+
+ Assert.NotNull(sawConflict);
+ Assert.Equal("appsettings.json", sawConflict!.RelativePath);
+ Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.json")));
+ // Previous user copy moved to .old/ (so the next-startup cleanup sweep removes it).
+ Assert.Equal("user-edited", File.ReadAllText(Path.Combine(oldDir, "appsettings.json")));
+ }
+
+ [Fact]
+ public async Task SwapAsync_preserved_glob_does_not_block_unrelated_release_files()
+ {
+ using var work = new TempDir();
+ var installDir = work.Combine("install");
+ var sourceDir = work.Combine("src");
+ var oldDir = Path.Combine(installDir, ".old");
+ Directory.CreateDirectory(installDir);
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(installDir, "myapp.db"), "user-db");
+ File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary");
+ File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary");
+ // Note: source does NOT ship myapp.db.
+
+ await UpdateInstaller.SwapAsync(
+ sourceDir, installDir, oldDir,
+ preservePaths: PreserveDb,
+ onConflict: null,
+ CancellationToken.None);
+
+ Assert.Equal("user-db", File.ReadAllText(Path.Combine(installDir, "myapp.db")));
+ Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe")));
+ }
+
+ [Fact]
+ public async Task SwapAsync_preserved_path_introduced_by_new_release_when_user_has_none_just_copies()
+ {
+ using var work = new TempDir();
+ var installDir = work.Combine("install");
+ var sourceDir = work.Combine("src");
+ var oldDir = Path.Combine(installDir, ".old");
+ Directory.CreateDirectory(installDir);
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(sourceDir, "appsettings.Production.json"), "release-default");
+
+ // The path is preservable but the user doesn't have a copy yet —
+ // installer should just place the new file with no resolver call.
+ var resolverCalled = false;
+ Func> resolver =
+ (_, _) => { resolverCalled = true; return Task.FromResult(UpdateConflictResolution.KeepExisting); };
+
+ await UpdateInstaller.SwapAsync(
+ sourceDir, installDir, oldDir,
+ preservePaths: PreserveAppsettingsGlob,
+ onConflict: resolver,
+ CancellationToken.None);
+
+ Assert.False(resolverCalled);
+ Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.Production.json")));
+ }
+
[Fact]
public void Swap_preserves_maintenance_entries()
{