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
56 changes: 47 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- **`<DebugType>portable</DebugType>`** 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<UpdateConflict, CancellationToken, Task<UpdateConflictResolution>>?` 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 (`<IncludeSymbols>true</IncludeSymbols>` + `<SymbolPackageFormat>snupkg</SymbolPackageFormat>` + `<DebugType>embedded</DebugType>`) 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 `<DebugType>portable</DebugType>` 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/<tag>/`. 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

- `<GeneratePackageOnBuild>` 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.
Expand All @@ -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
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>`, so
consumers can populate it from `IConfiguration` and merge with their
in-code defaults:

```csharp
var fromConfig = configuration
.GetSection("SelfUpdate:Preserve")
.Get<string[]>() ?? Array.Empty<string>();

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 |
Expand All @@ -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). |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// How to resolve conflicts where a new release ships an entry whose
/// path matches one of <c>SelfUpdaterOptions.PreservePaths</c>:
/// <c>ask</c> prompts per file, <c>keep</c> always keeps the user's,
/// <c>new</c> always uses the new release's. Default <c>ask</c> when
/// running interactively (no <c>--yes</c>), <c>keep</c> with <c>--yes</c>.
/// </summary>
[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;
Expand Down Expand Up @@ -99,13 +112,16 @@ protected override async Task<int> 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<UpdateProgressEvent>(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)
Expand All @@ -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<UpdateConflict, CancellationToken, Task<UpdateConflictResolution>>? 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<UpdateConflictResolution> 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
Expand Down
13 changes: 11 additions & 2 deletions src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public interface ISelfUpdater
/// latest release on the configured channel. Returns
/// <see langword="null"/> when nothing is available. Use this when
/// you want to display "what would be installed?" to the user
/// before calling <see cref="InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}, CancellationToken)"/>
/// before calling <see cref="InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}, Func{UpdateConflict, CancellationToken, Task{UpdateConflictResolution}}, CancellationToken)"/>
/// — 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).
Expand All @@ -34,9 +34,18 @@ public interface ISelfUpdater
/// user a specific release — passing it back avoids the second
/// source query the parameterless overload performs.
/// </summary>
/// <param name="release">The release to install.</param>
/// <param name="progress">Optional progress sink for stage-level events.</param>
/// <param name="onConflict">
/// Optional resolver invoked when a new release entry lands on a
/// path covered by <see cref="SelfUpdaterOptions.PreservePaths"/>.
/// <see langword="null"/> (default) keeps the user's existing file.
/// </param>
/// <param name="ct">Cancellation token.</param>
Task InstallAsync(
RemoteRelease release,
IProgress<UpdateProgressEvent>? progress = null,
Func<UpdateConflict, CancellationToken, Task<UpdateConflictResolution>>? onConflict = null,
CancellationToken ct = default);

/// <summary>
Expand All @@ -47,7 +56,7 @@ Task InstallAsync(
/// release returned by the source here may differ from the one a
/// prior <see cref="CheckAsync"/> reported. For interactive UIs,
/// prefer the <see cref="GetLatestReleaseAsync"/> +
/// <see cref="InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}, CancellationToken)"/>
/// <see cref="InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}, Func{UpdateConflict, CancellationToken, Task{UpdateConflictResolution}}, CancellationToken)"/>
/// pair so the user confirms exactly the release that gets
/// installed.
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,21 @@ public interface IUpdateInstaller
/// </summary>
/// <param name="release">The resolved release to install.</param>
/// <param name="progress">Optional progress sink for stage-level events.</param>
/// <param name="onConflict">
/// Optional resolver invoked when a new release entry lands on a
/// path covered by <see cref="SelfUpdaterOptions.PreservePaths"/>.
/// <see langword="null"/> (default) means
/// <see cref="UpdateConflictResolution.KeepExisting"/> — 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.
/// </param>
/// <param name="ct">Cancellation token.</param>
Task InstallAsync(
RemoteRelease release,
IProgress<UpdateProgressEvent>? progress = null,
Func<UpdateConflict, CancellationToken, Task<UpdateConflictResolution>>? onConflict = null,
CancellationToken ct = default);

/// <summary>
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.2</Version>
<Version>0.1.3</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
Loading