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
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
StemCode.CLI/packages.lock.json
StemCode.Desktop/packages.lock.json
StemCode.Tests/packages.lock.json
StemCode.Voice/packages.lock.json

- name: Restore
run: dotnet restore ${{ env.SOLUTION_FILE }} --locked-mode
Expand Down Expand Up @@ -133,6 +134,7 @@ jobs:
StemCode.CLI/packages.lock.json
StemCode.Desktop/packages.lock.json
StemCode.Tests/packages.lock.json
StemCode.Voice/packages.lock.json

- name: Restore
run: dotnet restore ${{ env.SOLUTION_FILE }} --locked-mode
Expand Down Expand Up @@ -246,6 +248,46 @@ jobs:
rid: osx-arm64
artifact_name: desktop-osx-arm64

- display_name: Voice
app_id: voice
package_name: StemCode.Voice
project: StemCode.Voice/StemCode.Voice.csproj
runner: windows-2025
rid: win-x64
artifact_name: voice-win-x64

- display_name: Voice
app_id: voice
package_name: StemCode.Voice
project: StemCode.Voice/StemCode.Voice.csproj
runner: ubuntu-24.04
rid: linux-x64
artifact_name: voice-linux-x64

- display_name: Voice
app_id: voice
package_name: StemCode.Voice
project: StemCode.Voice/StemCode.Voice.csproj
runner: ubuntu-24.04-arm
rid: linux-arm64
artifact_name: voice-linux-arm64

- display_name: Voice
app_id: voice
package_name: StemCode.Voice
project: StemCode.Voice/StemCode.Voice.csproj
runner: macos-15-intel
rid: osx-x64
artifact_name: voice-osx-x64

- display_name: Voice
app_id: voice
package_name: StemCode.Voice
project: StemCode.Voice/StemCode.Voice.csproj
runner: macos-15
rid: osx-arm64
artifact_name: voice-osx-arm64

steps:
- name: Checkout
uses: actions/checkout@v5
Expand All @@ -262,6 +304,7 @@ jobs:
StemCode.CLI/packages.lock.json
StemCode.Desktop/packages.lock.json
StemCode.Tests/packages.lock.json
StemCode.Voice/packages.lock.json

- name: Prepare artifact directories
shell: pwsh
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using FluentAssertions;
using Moq;
using StemCode.Application.Abstractions;
using StemCode.Application.Commands;
using StemCode.Application.Models;
using StemCode.Domain.Models;

namespace StemCode.Tests.Application.Commands;

public sealed class UpdateVoiceSynchronizationCommandTests
{
[Fact]
public async Task ExecuteAsync_UpdateNow_Should_SynchronizeVoice_WhenCliIsAlreadyCurrent()
{
ApplicationUpdateInfo updateInfo = new(
"1.2.3",
"1.2.3",
new Uri("https://github.com/rizwan3d/StemCode/releases/latest"),
IsUpdateAvailable: false);
ApplicationUpdateInstallResult installResult = new(
IsSuccess: true,
"StemCode and Voice runtime synchronization installed: 1.2.3.");

Mock<IApplicationUpdateService> updateService = new(MockBehavior.Strict);
updateService
.Setup(service => service.CheckAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(updateInfo);
updateService
.Setup(service => service.InstallAsync(
updateInfo,
It.IsAny<IProgress<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(installResult);

Mock<IConfirmationPrompt> confirmationPrompt = new(MockBehavior.Strict);
Mock<IStemCodeInstanceService> instanceService = new(MockBehavior.Strict);
instanceService
.Setup(service => service.GetOtherRunningInstancesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<RunningStemCodeInstance>());
Mock<IStatusMessageWriter> statusMessageWriter = new();

UpdateCommandHandler sut = new(
updateService.Object,
confirmationPrompt.Object,
statusMessageWriter.Object,
instanceService.Object);

ReplCommandResult result = await sut.ExecuteAsync(
CreateContext("now"),
CancellationToken.None);

result.FeedbackKind.Should().Be(ReplFeedbackKind.Info);
result.Message.Should().Be(installResult.Message);
updateService.VerifyAll();
confirmationPrompt.VerifyNoOtherCalls();
instanceService.VerifyAll();
statusMessageWriter.Verify(
writer => writer.ShowInfoAsync(
It.Is<string>(message => message.Contains("Synchronizing") && message.Contains("Voice runtime")),
It.IsAny<CancellationToken>()),
Times.Once);
}

private static ReplCommandContext CreateContext(string argumentText)
{
ReplSessionContext session = new(
new AgentProviderProfile(ProviderKind.OpenAi, BaseUrl: null),
"gpt-4.1",
["gpt-4.1"]);

return new ReplCommandContext(
"update",
argumentText,
[argumentText],
$"/update {argumentText}",
session);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using FluentAssertions;
using StemCode.Application.Models;
using StemCode.Infrastructure.Secrets;
using StemCode.Infrastructure.Updates;
using StemCode.Tests.Infrastructure.Secrets.TestDoubles;

namespace StemCode.Tests.Infrastructure.Updates;

public sealed class GitHubApplicationUpdateVoiceSyncTests
{
[Fact]
public async Task InstallAsync_Should_RunInstaller_WhenCliIsCurrent_ToSynchronizeVoiceRuntime()
{
FakeProcessRunner processRunner = new();
processRunner.EnqueueResult(new ProcessExecutionResult(0, string.Empty, string.Empty));
GitHubApplicationUpdateService sut = new(new HttpClient(), processRunner);
ApplicationUpdateInfo updateInfo = new(
"1.2.3",
"1.2.3",
new Uri("https://github.com/rizwan3d/StemCode/releases/latest"),
IsUpdateAvailable: false);

ApplicationUpdateInstallResult result = await sut.InstallAsync(
updateInfo,
progress: null,
CancellationToken.None);

ProcessExecutionRequest request = processRunner.Requests.Should().ContainSingle().Subject;
request.EnvironmentVariables.Should().ContainKey("StemCode_TAG")
.WhoseValue.Should().Be("1.2.3");
result.IsSuccess.Should().BeTrue();
result.Message.Should().Contain("Voice runtime synchronization");
}
}
19 changes: 13 additions & 6 deletions StemCode/Application/Commands/ReplCommands/UpdateCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public UpdateCommandHandler(

public string CommandName => "update";

public string Description => "Check for StemCode updates and install the latest release.";
public string Description => "Check for StemCode updates and synchronize the matching Voice runtime.";

public string Usage => "/update [now]";

Expand Down Expand Up @@ -60,19 +60,22 @@ public async Task<ReplCommandResult> ExecuteAsync(
ReplFeedbackKind.Error);
}

if (!updateInfo.IsUpdateAvailable)
// Plain `/update` remains a check-and-update command. `/update now` is also
// a repair/synchronization path: even when the CLI is current, rerun the
// matching release installer so StemCode.Voice is guaranteed to match it.
if (!updateInfo.IsUpdateAvailable && !installWithoutPrompt)
{
return ReplCommandResult.Continue(
$"StemCode is up to date. Current version: {updateInfo.CurrentVersion}.",
ReplFeedbackKind.Info);
}

if (!installWithoutPrompt)
if (updateInfo.IsUpdateAvailable && !installWithoutPrompt)
{
bool shouldUpdate = await _confirmationPrompt.PromptAsync(
new ConfirmationPromptRequest(
"A StemCode update is available. Update now?",
$"Current: {updateInfo.CurrentVersion}. Latest: {updateInfo.LatestVersion}. Choose Yes to update now, or No to skip.",
"A StemCode update is available. Update CLI and Voice runtime now?",
$"Current: {updateInfo.CurrentVersion}. Latest: {updateInfo.LatestVersion}. Choose Yes to update StemCode and its matching Voice runtime now, or No to skip.",
DefaultValue: false),
cancellationToken);

Expand Down Expand Up @@ -119,8 +122,12 @@ await _statusMessageWriter.ShowInfoAsync(
}
}

string installStatus = updateInfo.IsUpdateAvailable
? $"Installing StemCode {updateInfo.LatestVersion} and matching Voice runtime..."
: $"Synchronizing StemCode {updateInfo.LatestVersion} and Voice runtime...";

await _statusMessageWriter.ShowInfoAsync(
$"Installing StemCode {updateInfo.LatestVersion}...",
installStatus,
cancellationToken);

ApplicationUpdateInstallResult installResult;
Expand Down
20 changes: 9 additions & 11 deletions StemCode/Infrastructure/Updates/GitHubApplicationUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,10 @@ public async Task<ApplicationUpdateInstallResult> InstallAsync(
{
ArgumentNullException.ThrowIfNull(updateInfo);

if (!updateInfo.IsUpdateAvailable)
{
return new ApplicationUpdateInstallResult(
true,
$"StemCode is already up to date ({updateInfo.CurrentVersion}).");
}

// Always run the matching release installer when installation is requested.
// Besides replacing the CLI, the installer synchronizes StemCode.Voice to
// the same release. This also lets `/update now` repair a missing or stale
// Voice runtime when the CLI itself is already on the latest version.
Action<string>? onOutputLine = progress is null
? null
: line =>
Expand All @@ -83,9 +80,10 @@ public async Task<ApplicationUpdateInstallResult> InstallAsync(

if (result.ExitCode == 0)
{
string operation = updateInfo.IsUpdateAvailable ? "update" : "synchronization";
string successMessage = OperatingSystem.IsWindows()
? $"StemCode update prepared: {updateInfo.LatestVersion}. Exit StemCode to finish installation, then restart it to use the new version."
: $"StemCode update installed: {updateInfo.LatestVersion}. Restart StemCode to use the new version.";
? $"StemCode and Voice runtime {operation} prepared: {updateInfo.LatestVersion}. Exit StemCode to finish installation, then restart it to use the synchronized release."
: $"StemCode and Voice runtime {operation} installed: {updateInfo.LatestVersion}. Restart StemCode to use the synchronized release.";

return new ApplicationUpdateInstallResult(
true,
Expand All @@ -101,8 +99,8 @@ public async Task<ApplicationUpdateInstallResult> InstallAsync(
return new ApplicationUpdateInstallResult(
false,
string.IsNullOrWhiteSpace(detail)
? $"StemCode update failed with exit code {result.ExitCode}. Download it manually from {updateInfo.ReleaseUri}."
: $"StemCode update failed with exit code {result.ExitCode}: {Truncate(detail, 600)}");
? $"StemCode and Voice runtime update failed with exit code {result.ExitCode}. Download the release manually from {updateInfo.ReleaseUri}."
: $"StemCode and Voice runtime update failed with exit code {result.ExitCode}: {Truncate(detail, 600)}");
}

private static ProcessExecutionRequest CreateInstallRequest(
Expand Down
Loading
Loading