Skip to content

Commit 3090617

Browse files
committed
Add: installer's step-by-step progress in CLI
1 parent fb647cb commit 3090617

10 files changed

Lines changed: 203 additions & 32 deletions

File tree

NanoAgent.Tests/Application/Commands/UpdateCommandHandlerTests.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,16 @@ public async Task ExecuteAsync_Should_ReportCurrentVersion_When_NoUpdateIsAvaila
2424
.ReturnsAsync(updateInfo);
2525

2626
Mock<IConfirmationPrompt> confirmationPrompt = new(MockBehavior.Strict);
27-
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object);
27+
Mock<IStatusMessageWriter> statusMessageWriter = new();
28+
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object, statusMessageWriter.Object);
2829

2930
ReplCommandResult result = await sut.ExecuteAsync(
3031
CreateContext(argumentText: string.Empty),
3132
CancellationToken.None);
3233

3334
result.FeedbackKind.Should().Be(ReplFeedbackKind.Info);
3435
result.Message.Should().Be("NanoAgent is up to date. Current version: 1.2.3.");
35-
updateService.Verify(service => service.InstallAsync(It.IsAny<ApplicationUpdateInfo>(), It.IsAny<CancellationToken>()), Times.Never);
36+
updateService.Verify(service => service.InstallAsync(It.IsAny<ApplicationUpdateInfo>(), It.IsAny<IProgress<string>>(), It.IsAny<CancellationToken>()), Times.Never);
3637
confirmationPrompt.VerifyNoOtherCalls();
3738
}
3839

@@ -53,11 +54,12 @@ public async Task ExecuteAsync_Should_InstallUpdate_When_NowArgumentIsUsed()
5354
.Setup(service => service.CheckAsync(It.IsAny<CancellationToken>()))
5455
.ReturnsAsync(updateInfo);
5556
updateService
56-
.Setup(service => service.InstallAsync(updateInfo, It.IsAny<CancellationToken>()))
57+
.Setup(service => service.InstallAsync(updateInfo, It.IsAny<IProgress<string>>(), It.IsAny<CancellationToken>()))
5758
.ReturnsAsync(installResult);
5859

5960
Mock<IConfirmationPrompt> confirmationPrompt = new(MockBehavior.Strict);
60-
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object);
61+
Mock<IStatusMessageWriter> statusMessageWriter = new();
62+
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object, statusMessageWriter.Object);
6163

6264
ReplCommandResult result = await sut.ExecuteAsync(
6365
CreateContext("now"),
@@ -88,15 +90,16 @@ public async Task ExecuteAsync_Should_SkipInstall_When_UserDeclinesPrompt()
8890
.Setup(prompt => prompt.PromptAsync(It.IsAny<ConfirmationPromptRequest>(), It.IsAny<CancellationToken>()))
8991
.ReturnsAsync(false);
9092

91-
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object);
93+
Mock<IStatusMessageWriter> statusMessageWriter = new();
94+
UpdateCommandHandler sut = new(updateService.Object, confirmationPrompt.Object, statusMessageWriter.Object);
9295

9396
ReplCommandResult result = await sut.ExecuteAsync(
9497
CreateContext(argumentText: string.Empty),
9598
CancellationToken.None);
9699

97100
result.FeedbackKind.Should().Be(ReplFeedbackKind.Info);
98101
result.Message.Should().Contain("Skipped NanoAgent 1.2.4.");
99-
updateService.Verify(service => service.InstallAsync(It.IsAny<ApplicationUpdateInfo>(), It.IsAny<CancellationToken>()), Times.Never);
102+
updateService.Verify(service => service.InstallAsync(It.IsAny<ApplicationUpdateInfo>(), It.IsAny<IProgress<string>>(), It.IsAny<CancellationToken>()), Times.Never);
100103
confirmationPrompt.VerifyAll();
101104
}
102105

NanoAgent.Tests/Infrastructure/Secrets/TestDoubles/FakeProcessRunner.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ namespace NanoAgent.Tests.Infrastructure.Secrets.TestDoubles;
55
internal sealed class FakeProcessRunner : IProcessRunner
66
{
77
private readonly Queue<ProcessExecutionResult> _results = new();
8+
private readonly Queue<IReadOnlyList<string>> _outputLines = new();
89

910
public List<ProcessExecutionRequest> Requests { get; } = [];
1011

11-
public void EnqueueResult(ProcessExecutionResult result)
12+
public void EnqueueResult(ProcessExecutionResult result, params string[] outputLines)
1213
{
1314
_results.Enqueue(result);
15+
_outputLines.Enqueue(outputLines);
1416
}
1517

1618
public Task<ProcessExecutionResult> RunAsync(
@@ -25,6 +27,18 @@ public Task<ProcessExecutionResult> RunAsync(
2527
throw new InvalidOperationException("No queued process result is available.");
2628
}
2729

30+
IReadOnlyList<string> lines = _outputLines.Count > 0
31+
? _outputLines.Dequeue()
32+
: [];
33+
34+
if (request.OnOutputLine is not null)
35+
{
36+
foreach (string line in lines)
37+
{
38+
request.OnOutputLine(line);
39+
}
40+
}
41+
2842
return Task.FromResult(_results.Dequeue());
2943
}
3044
}

NanoAgent.Tests/Infrastructure/Updates/GitHubApplicationUpdateServiceTests.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public async Task InstallAsync_Should_PassCurrentProcessIdToWindowsInstaller()
2323

2424
ApplicationUpdateInstallResult result = await sut.InstallAsync(
2525
updateInfo,
26+
progress: null,
2627
CancellationToken.None);
2728

2829
ProcessExecutionRequest request = processRunner.Requests.Single();
@@ -55,7 +56,7 @@ public async Task InstallAsync_Should_TargetTheRunningBinaryLocation()
5556
new Uri("https://github.com/rizwan3d/NanoAgent/releases/latest"),
5657
IsUpdateAvailable: true);
5758

58-
await sut.InstallAsync(updateInfo, CancellationToken.None);
59+
await sut.InstallAsync(updateInfo, progress: null, CancellationToken.None);
5960

6061
ProcessExecutionRequest request = processRunner.Requests.Single();
6162

@@ -79,6 +80,42 @@ public async Task InstallAsync_Should_TargetTheRunningBinaryLocation()
7980
}
8081
}
8182

83+
[Fact]
84+
public async Task InstallAsync_Should_StreamInstallerOutputLinesToProgress()
85+
{
86+
FakeProcessRunner processRunner = new();
87+
processRunner.EnqueueResult(
88+
new ProcessExecutionResult(0, string.Empty, string.Empty),
89+
"[NanoAgent.CLI] [1/7] Checking system requirements...",
90+
" ",
91+
"[NanoAgent.CLI] [2/7] Resolving release...");
92+
GitHubApplicationUpdateService sut = new(new HttpClient(), processRunner);
93+
ApplicationUpdateInfo updateInfo = new(
94+
"1.2.3",
95+
"1.2.4",
96+
new Uri("https://github.com/rizwan3d/NanoAgent/releases/latest"),
97+
IsUpdateAvailable: true);
98+
99+
List<string> reported = [];
100+
CapturingProgress progress = new(reported);
101+
102+
await sut.InstallAsync(updateInfo, progress, CancellationToken.None);
103+
104+
// Whitespace-only lines are skipped; meaningful step lines stream through in order.
105+
reported.Should().Equal(
106+
"[NanoAgent.CLI] [1/7] Checking system requirements...",
107+
"[NanoAgent.CLI] [2/7] Resolving release...");
108+
}
109+
110+
private sealed class CapturingProgress : IProgress<string>
111+
{
112+
private readonly List<string> _values;
113+
114+
public CapturingProgress(List<string> values) => _values = values;
115+
116+
public void Report(string value) => _values.Add(value);
117+
}
118+
82119
[Fact]
83120
public void TryResolveRunningInstallLocation_Should_StripExecutableExtensionForWindows()
84121
{

NanoAgent/Application/Abstractions/IApplicationUpdateService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ public interface IApplicationUpdateService
88

99
Task<ApplicationUpdateInstallResult> InstallAsync(
1010
ApplicationUpdateInfo updateInfo,
11+
IProgress<string>? progress,
1112
CancellationToken cancellationToken);
1213
}

NanoAgent/Application/Backend/NanoAgentBackend.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,10 +493,17 @@ await _statusMessageWriter.ShowInfoAsync(
493493
return;
494494
}
495495

496+
await _statusMessageWriter.ShowInfoAsync(
497+
$"Installing NanoAgent {updateInfo.LatestVersion}...",
498+
cancellationToken);
499+
496500
ApplicationUpdateInstallResult installResult;
497501
try
498502
{
499-
installResult = await _updateService.InstallAsync(updateInfo, cancellationToken);
503+
installResult = await _updateService.InstallAsync(
504+
updateInfo,
505+
new StatusMessageProgress(_statusMessageWriter),
506+
cancellationToken);
500507
}
501508
catch (Exception exception) when (
502509
exception is InvalidOperationException or

NanoAgent/Application/Commands/ReplCommands/UpdateCommandHandler.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
using NanoAgent.Application.Abstractions;
22
using NanoAgent.Application.Models;
3+
using NanoAgent.Application.UI;
34

45
namespace NanoAgent.Application.Commands;
56

67
internal sealed class UpdateCommandHandler : IReplCommandHandler
78
{
89
private readonly IApplicationUpdateService _updateService;
910
private readonly IConfirmationPrompt _confirmationPrompt;
11+
private readonly IStatusMessageWriter _statusMessageWriter;
1012

1113
public UpdateCommandHandler(
1214
IApplicationUpdateService updateService,
13-
IConfirmationPrompt confirmationPrompt)
15+
IConfirmationPrompt confirmationPrompt,
16+
IStatusMessageWriter statusMessageWriter)
1417
{
1518
_updateService = updateService;
1619
_confirmationPrompt = confirmationPrompt;
20+
_statusMessageWriter = statusMessageWriter;
1721
}
1822

1923
public string CommandName => "update";
@@ -77,10 +81,17 @@ public async Task<ReplCommandResult> ExecuteAsync(
7781
}
7882
}
7983

84+
await _statusMessageWriter.ShowInfoAsync(
85+
$"Installing NanoAgent {updateInfo.LatestVersion}...",
86+
cancellationToken);
87+
8088
ApplicationUpdateInstallResult installResult;
8189
try
8290
{
83-
installResult = await _updateService.InstallAsync(updateInfo, cancellationToken);
91+
installResult = await _updateService.InstallAsync(
92+
updateInfo,
93+
new StatusMessageProgress(_statusMessageWriter),
94+
cancellationToken);
8495
}
8596
catch (Exception exception) when (
8697
exception is InvalidOperationException or
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using NanoAgent.Application.Abstractions;
2+
3+
namespace NanoAgent.Application.UI;
4+
5+
/// <summary>
6+
/// Adapts <see cref="IStatusMessageWriter"/> to <see cref="IProgress{T}"/> so long-running
7+
/// operations (such as installing an update) can stream their output to the UI as info
8+
/// messages line by line.
9+
/// </summary>
10+
/// <remarks>
11+
/// Reports synchronously on the calling thread, unlike <see cref="Progress{T}"/> which posts
12+
/// to a captured synchronization context. In a console host there is no such context, so
13+
/// <see cref="Progress{T}"/> would defer callbacks to the thread pool and the lines could
14+
/// render out of order or after the final result. Reporting inline keeps them ordered.
15+
/// </remarks>
16+
public sealed class StatusMessageProgress : IProgress<string>
17+
{
18+
private readonly IStatusMessageWriter _statusMessageWriter;
19+
20+
public StatusMessageProgress(IStatusMessageWriter statusMessageWriter)
21+
{
22+
ArgumentNullException.ThrowIfNull(statusMessageWriter);
23+
_statusMessageWriter = statusMessageWriter;
24+
}
25+
26+
public void Report(string value)
27+
{
28+
if (string.IsNullOrWhiteSpace(value))
29+
{
30+
return;
31+
}
32+
33+
// Progress is best-effort and must never disrupt the underlying operation, which
34+
// may be reporting from a background reader thread. CancellationToken.None ensures a
35+
// cancelled operation can still flush its trailing lines; the writer completes
36+
// synchronously, so discarding the task does not reorder output.
37+
try
38+
{
39+
_ = _statusMessageWriter.ShowInfoAsync(value, CancellationToken.None);
40+
}
41+
catch (Exception)
42+
{
43+
}
44+
}
45+
}

NanoAgent/Infrastructure/Secrets/ProcessExecutionRequest.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ internal sealed record ProcessExecutionRequest(
77
string? WorkingDirectory = null,
88
int? MaxOutputCharacters = null,
99
IReadOnlyDictionary<string, string>? EnvironmentVariables = null,
10-
bool UsePseudoTerminal = false);
10+
bool UsePseudoTerminal = false,
11+
// Optional: invoked with each completed stdout/stderr line as it is read, in
12+
// addition to the captured output, so callers can stream live progress. The
13+
// callback may run on a background reader thread and must be thread-safe.
14+
Action<string>? OnOutputLine = null);

NanoAgent/Infrastructure/Secrets/ProcessRunner.cs

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,13 @@ private async Task<ProcessExecutionResult> RunDirectAsync(
8989
Task<string> standardOutputTask = ReadToEndCappedAsync(
9090
process.StandardOutput,
9191
request.MaxOutputCharacters,
92-
cancellationToken);
92+
cancellationToken,
93+
request.OnOutputLine);
9394
Task<string> standardErrorTask = ReadToEndCappedAsync(
9495
process.StandardError,
9596
request.MaxOutputCharacters,
96-
cancellationToken);
97+
cancellationToken,
98+
request.OnOutputLine);
9799

98100
if (request.StandardInput is not null)
99101
{
@@ -552,20 +554,20 @@ private static void TryKillProcess(Process process)
552554
private static async Task<string> ReadToEndCappedAsync(
553555
TextReader reader,
554556
int? maxCharacters,
555-
CancellationToken cancellationToken)
557+
CancellationToken cancellationToken,
558+
Action<string>? onLine = null)
556559
{
557560
const int BufferSize = 4096;
558561

559-
if (maxCharacters is <= 0)
560-
{
561-
await DrainAsync(reader, cancellationToken);
562-
return string.Empty;
563-
}
562+
// Capture stops once the cap is exhausted (or is non-positive), but line
563+
// streaming must keep draining so 'onLine' sees every line to the end.
564+
bool capture = maxCharacters is not <= 0;
564565

565566
char[] buffer = new char[BufferSize];
566-
StringBuilder builder = maxCharacters is null
567-
? new StringBuilder()
568-
: new StringBuilder(Math.Min(maxCharacters.Value, BufferSize));
567+
StringBuilder builder = maxCharacters is > 0
568+
? new StringBuilder(Math.Min(maxCharacters.Value, BufferSize))
569+
: new StringBuilder();
570+
StringBuilder? lineBuffer = onLine is null ? null : new StringBuilder();
569571
bool truncated = false;
570572

571573
while (true)
@@ -578,6 +580,16 @@ private static async Task<string> ReadToEndCappedAsync(
578580
break;
579581
}
580582

583+
if (lineBuffer is not null)
584+
{
585+
EmitCompletedLines(buffer, read, lineBuffer, onLine!);
586+
}
587+
588+
if (!capture)
589+
{
590+
continue;
591+
}
592+
581593
if (maxCharacters is null)
582594
{
583595
builder.Append(buffer, 0, read);
@@ -596,6 +608,11 @@ private static async Task<string> ReadToEndCappedAsync(
596608
truncated |= charactersToAppend < read;
597609
}
598610

611+
if (lineBuffer is { Length: > 0 })
612+
{
613+
onLine!(lineBuffer.ToString());
614+
}
615+
599616
if (truncated && maxCharacters is > 3)
600617
{
601618
builder.Length = Math.Min(builder.Length, maxCharacters.Value - 3);
@@ -605,13 +622,29 @@ private static async Task<string> ReadToEndCappedAsync(
605622
return builder.ToString();
606623
}
607624

608-
private static async Task DrainAsync(
609-
TextReader reader,
610-
CancellationToken cancellationToken)
625+
private static void EmitCompletedLines(
626+
char[] buffer,
627+
int count,
628+
StringBuilder lineBuffer,
629+
Action<string> onLine)
611630
{
612-
char[] buffer = new char[4096];
613-
while (await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) > 0)
631+
for (int index = 0; index < count; index++)
614632
{
633+
char character = buffer[index];
634+
if (character != '\n')
635+
{
636+
lineBuffer.Append(character);
637+
continue;
638+
}
639+
640+
// Strip a trailing '\r' so CRLF-terminated lines stream cleanly.
641+
if (lineBuffer.Length > 0 && lineBuffer[^1] == '\r')
642+
{
643+
lineBuffer.Length--;
644+
}
645+
646+
onLine(lineBuffer.ToString());
647+
lineBuffer.Clear();
615648
}
616649
}
617650

0 commit comments

Comments
 (0)