feat(secrets-cli): recent-targets store for the interactive console

This commit is contained in:
Joseph Doherty
2026-07-19 08:50:03 -04:00
parent fe9d8865fe
commit 1b013a56a7
2 changed files with 219 additions and 0 deletions
@@ -0,0 +1,92 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never
/// key material or secret values) so the interactive console can offer quick re-selection.
/// </summary>
public sealed class RecentTargetsStore
{
private const int MaxEntries = 10;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
private readonly string _filePath;
private readonly TimeProvider _timeProvider;
/// <summary>Creates the store bound to a JSON file path, using an optional injected clock.</summary>
/// <param name="filePath">The JSON file to load from and save to.</param>
/// <param name="timeProvider">
/// Clock used to stamp <see cref="RecentTarget.LastUsedUtc"/>. Defaults to
/// <see cref="TimeProvider.System"/> when omitted.
/// </param>
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
{
_filePath = filePath;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>One remembered target: a display name, its appsettings.json path, and when it was last used.</summary>
/// <param name="Name">Display name for the target (e.g. the app name).</param>
/// <param name="AppSettingsPath">Filesystem path to the target's appsettings.json.</param>
/// <param name="LastUsedUtc">Timestamp of the most recent selection of this target.</param>
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
/// <summary>Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.</summary>
public IReadOnlyList<RecentTarget> Load()
{
if (!File.Exists(_filePath))
{
return [];
}
try
{
var json = File.ReadAllText(_filePath);
var entries = JsonSerializer.Deserialize<List<RecentTarget>>(json, SerializerOptions);
return entries ?? [];
}
catch (JsonException)
{
return [];
}
catch (IOException)
{
return [];
}
}
/// <summary>Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.</summary>
/// <param name="name">Display name for the target.</param>
/// <param name="appSettingsPath">Filesystem path to the target's appsettings.json — the dedup key.</param>
public void Touch(string name, string appSettingsPath)
{
var existing = Load();
var remaining = existing.Where(e => !string.Equals(e.AppSettingsPath, appSettingsPath, StringComparison.Ordinal));
var touched = new RecentTarget(name, appSettingsPath, _timeProvider.GetUtcNow());
var updated = new List<RecentTarget> { touched };
updated.AddRange(remaining.Take(MaxEntries - 1));
Save(updated);
}
private void Save(IReadOnlyList<RecentTarget> entries)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(entries, SerializerOptions);
var tmpPath = _filePath + ".tmp";
File.WriteAllText(tmpPath, json);
File.Move(tmpPath, _filePath, overwrite: true);
}
}