From 306b5745bcbd37a35c7f7723611f0a5f8f580c4c Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:11:53 +0500 Subject: [PATCH 01/12] Add Voice release artifacts --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad3a4c4c..641ad5c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -306,9 +349,9 @@ jobs: } $scriptPath = Join-Path $env:RUNNER_TEMP "StemCode.Desktop.iss" - $publishDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/publish/${{ matrix.app_id }}/${{ matrix.rid }}").Replace('\', '\\') - $outputDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/packages").Replace('\', '\\') - $setupIconFile = (Join-Path $env:GITHUB_WORKSPACE "StemCode.Desktop/Assets/logo.ico").Replace('\', '\\') + $publishDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/publish/${{ matrix.app_id }}/${{ matrix.rid }}").Replace('\\', '\\\\') + $outputDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/packages").Replace('\\', '\\\\') + $setupIconFile = (Join-Path $env:GITHUB_WORKSPACE "StemCode.Desktop/Assets/logo.ico").Replace('\\', '\\\\') @" #define MyAppName "StemCode Desktop" @@ -321,7 +364,7 @@ jobs: AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} - DefaultDirName={autopf}\StemCode Desktop + DefaultDirName={autopf}\\StemCode Desktop DefaultGroupName=StemCode Desktop DisableProgramGroupPage=yes OutputDir=$outputDir @@ -334,20 +377,20 @@ jobs: ArchitecturesInstallIn64BitMode=x64compatible [Files] - Source: "$publishDir\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + Source: "$publishDir\\\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] - Name: "{autoprograms}\StemCode Desktop"; Filename: "{app}\{#MyAppExeName}" - Name: "{autodesktop}\StemCode Desktop"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + Name: "{autoprograms}\\StemCode Desktop"; Filename: "{app}\\{#MyAppExeName}" + Name: "{autodesktop}\\StemCode Desktop"; Filename: "{app}\\{#MyAppExeName}"; Tasks: desktopicon [Tasks] Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked [Run] - Filename: "{app}\{#MyAppExeName}"; Description: "Launch StemCode Desktop"; Flags: nowait postinstall skipifsilent + Filename: "{app}\\{#MyAppExeName}"; Description: "Launch StemCode Desktop"; Flags: nowait postinstall skipifsilent "@ | Set-Content -Path $scriptPath -Encoding UTF8 - & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" $scriptPath + & "${env:ProgramFiles(x86)}\\Inno Setup 6\\ISCC.exe" $scriptPath - name: Package published output on Linux and macOS if: runner.os != 'Windows' From 3baa46297c1838d50f108692711ca84c513b2247 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:13:04 +0500 Subject: [PATCH 02/12] Restore installer path escaping --- .github/workflows/release.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 641ad5c2..8faed39e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,9 +349,9 @@ jobs: } $scriptPath = Join-Path $env:RUNNER_TEMP "StemCode.Desktop.iss" - $publishDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/publish/${{ matrix.app_id }}/${{ matrix.rid }}").Replace('\\', '\\\\') - $outputDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/packages").Replace('\\', '\\\\') - $setupIconFile = (Join-Path $env:GITHUB_WORKSPACE "StemCode.Desktop/Assets/logo.ico").Replace('\\', '\\\\') + $publishDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/publish/${{ matrix.app_id }}/${{ matrix.rid }}").Replace('\', '\\') + $outputDir = (Join-Path $env:GITHUB_WORKSPACE "artifacts/packages").Replace('\', '\\') + $setupIconFile = (Join-Path $env:GITHUB_WORKSPACE "StemCode.Desktop/Assets/logo.ico").Replace('\', '\\') @" #define MyAppName "StemCode Desktop" @@ -364,7 +364,7 @@ jobs: AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} - DefaultDirName={autopf}\\StemCode Desktop + DefaultDirName={autopf}\StemCode Desktop DefaultGroupName=StemCode Desktop DisableProgramGroupPage=yes OutputDir=$outputDir @@ -377,20 +377,20 @@ jobs: ArchitecturesInstallIn64BitMode=x64compatible [Files] - Source: "$publishDir\\\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + Source: "$publishDir\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] - Name: "{autoprograms}\\StemCode Desktop"; Filename: "{app}\\{#MyAppExeName}" - Name: "{autodesktop}\\StemCode Desktop"; Filename: "{app}\\{#MyAppExeName}"; Tasks: desktopicon + Name: "{autoprograms}\StemCode Desktop"; Filename: "{app}\{#MyAppExeName}" + Name: "{autodesktop}\StemCode Desktop"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Tasks] Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked [Run] - Filename: "{app}\\{#MyAppExeName}"; Description: "Launch StemCode Desktop"; Flags: nowait postinstall skipifsilent + Filename: "{app}\{#MyAppExeName}"; Description: "Launch StemCode Desktop"; Flags: nowait postinstall skipifsilent "@ | Set-Content -Path $scriptPath -Encoding UTF8 - & "${env:ProgramFiles(x86)}\\Inno Setup 6\\ISCC.exe" $scriptPath + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" $scriptPath - name: Package published output on Linux and macOS if: runner.os != 'Windows' From 5129039a8049ebc37edbf51f74e6381073bd739c Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:20:32 +0500 Subject: [PATCH 03/12] Install Voice runtime from npm package --- packaging/npm/scripts/platform.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packaging/npm/scripts/platform.js b/packaging/npm/scripts/platform.js index e85c7ecb..81449b99 100644 --- a/packaging/npm/scripts/platform.js +++ b/packaging/npm/scripts/platform.js @@ -9,8 +9,10 @@ const path = require("path"); const OWNER = "rizwan3d"; const REPO = "StemCode"; const APP_NAME = "StemCode.CLI"; +const VOICE_APP_NAME = "StemCode.Voice"; // Executable name inside the release archive (matches the AOT-published output). const EXECUTABLE_NAME = "StemCode.CLI"; +const VOICE_EXECUTABLE_NAME = "stemcode-voice"; const CHECKSUMS_NAME = "SHA256SUMS"; // Maps Node's process.platform/process.arch onto the .NET runtime identifiers @@ -43,6 +45,10 @@ function executableFileName() { return process.platform === "win32" ? `${EXECUTABLE_NAME}.exe` : EXECUTABLE_NAME; } +function voiceExecutableFileName() { + return process.platform === "win32" ? `${VOICE_EXECUTABLE_NAME}.exe` : VOICE_EXECUTABLE_NAME; +} + // Version baked into package.json by the release workflow; the matching GitHub // release is tagged "V" (uppercase V). Historically releases have also // shipped under lowercase "v". The release pipelines strip a leading @@ -90,28 +96,46 @@ function assetName(rid) { return `${APP_NAME}-${rid}.zip`; } +function voiceAssetName(rid) { + return `${VOICE_APP_NAME}-${rid}.zip`; +} + function vendorDir() { return path.join(__dirname, "..", "vendor"); } +function voiceDir() { + return path.join(vendorDir(), "voice"); +} + function installedBinaryPath() { return path.join(vendorDir(), executableFileName()); } +function installedVoiceBinaryPath() { + return path.join(voiceDir(), voiceExecutableFileName()); +} + module.exports = { OWNER, REPO, APP_NAME, + VOICE_APP_NAME, EXECUTABLE_NAME, + VOICE_EXECUTABLE_NAME, CHECKSUMS_NAME, resolveRid, executableFileName, + voiceExecutableFileName, resolveVersion, resolveTag, alternateTag, baseDownloadUrl, assetName, + voiceAssetName, vendorDir, + voiceDir, installedBinaryPath, + installedVoiceBinaryPath, homedir: os.homedir, }; From 90c951192af646f7a3d85768894f53783e1524dc Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:20:56 +0500 Subject: [PATCH 04/12] Install Voice runtime from npm package --- packaging/npm/scripts/download.js | 92 +++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/packaging/npm/scripts/download.js b/packaging/npm/scripts/download.js index a3f437a6..0e6ca454 100644 --- a/packaging/npm/scripts/download.js +++ b/packaging/npm/scripts/download.js @@ -1,9 +1,8 @@ "use strict"; -// Downloads, verifies, and extracts the StemCode CLI binary from the matching -// GitHub release. Shared by the postinstall step and the runtime launcher so the -// CLI self-heals on first run even when a package manager skips lifecycle -// scripts (notably `bun install`, which does not run postinstall by default). +// Downloads, verifies, and extracts the StemCode CLI binary and matching Voice +// runtime from the GitHub release. Shared by the postinstall step and runtime +// launcher so installations self-heal when lifecycle scripts were skipped. const fs = require("fs"); const path = require("path"); @@ -79,6 +78,23 @@ function extractExecutable(zipBuffer, destinationPath) { } } +function extractVoiceRuntime(zipBuffer, destinationDir) { + fs.mkdirSync(destinationDir, { recursive: true }); + const zip = new AdmZip(zipBuffer); + zip.extractAllTo(destinationDir, true); + + const voiceBinary = path.join(destinationDir, platform.voiceExecutableFileName()); + if (!fs.existsSync(voiceBinary)) { + throw new Error( + `Voice release archive did not contain the expected executable '${platform.voiceExecutableFileName()}'.` + ); + } + + if (process.platform !== "win32") { + fs.chmodSync(voiceBinary, 0o755); + } +} + // Fetches and SHA256-verifies the release archive for a single tag. // Returns the archive Buffer, or null when the asset is missing (HTTP 404). async function downloadForTag(candidateTag, asset, log) { @@ -92,7 +108,7 @@ async function downloadForTag(candidateTag, asset, log) { return null; } - log(`Verifying ${platform.CHECKSUMS_NAME}...`); + log(`Verifying ${platform.CHECKSUMS_NAME} for ${asset}...`); const checksumsText = (await fetchBuffer(checksumsUrl)).toString("utf8"); const expected = parseExpectedChecksum(checksumsText, asset); if (!expected) { @@ -109,26 +125,26 @@ async function downloadForTag(candidateTag, asset, log) { return archiveBuffer; } -// Ensures the platform binary is present in vendor/. Returns the absolute path. -// `onDownloaded` is awaited only when a fresh (non-update) binary is fetched, so -// callers can record an anonymous install event exactly once per real install. +// Ensures the platform CLI and Voice runtime are present in vendor/. Returns the +// absolute CLI path. `onDownloaded` is awaited only for a fresh installation. async function ensureBinary(options = {}) { const { force = false, log = () => {}, tag, onDownloaded } = options; const binaryPath = platform.installedBinaryPath(); - if (!force && fs.existsSync(binaryPath)) { + const voiceBinaryPath = platform.installedVoiceBinaryPath(); + if (!force && fs.existsSync(binaryPath) && fs.existsSync(voiceBinaryPath)) { return binaryPath; } const rid = platform.resolveRid(); const asset = platform.assetName(rid); + const voiceAsset = platform.voiceAssetName(rid); const resolvedTag = tag && tag.trim() ? tag.trim() : platform.resolveTag(); - // GitHub release tags have shipped under both casings ("v1.1.10" and - // "V1.1.10"). Try the resolved tag first, then its alternate-case variant, - // so installs and updates succeed regardless of how the release was tagged. + // Release tags have shipped under both v/V casings. Use one casing for both + // archives so the CLI and Voice runtime always come from the same release. const candidateTags = [resolvedTag]; const alternate = platform.alternateTag(resolvedTag); if (alternate && alternate !== resolvedTag) { @@ -136,46 +152,66 @@ async function ensureBinary(options = {}) { } let archiveBuffer = null; + let voiceArchiveBuffer = null; let usedTag = null; let lastError = null; for (const candidateTag of candidateTags) { try { - const buffer = await downloadForTag(candidateTag, asset, log); - if (buffer) { - archiveBuffer = buffer; - usedTag = candidateTag; - break; + const cliBuffer = await downloadForTag(candidateTag, asset, log); + if (!cliBuffer) { + continue; + } + + const voiceBuffer = await downloadForTag(candidateTag, voiceAsset, log); + if (!voiceBuffer) { + continue; } + + archiveBuffer = cliBuffer; + voiceArchiveBuffer = voiceBuffer; + usedTag = candidateTag; + break; } catch (err) { - // A 404 under one casing is the common case and simply means "try the - // other casing". Other errors (checksum mismatch, network) are recorded - // so they can be surfaced if no candidate succeeds. lastError = err; } } - if (!archiveBuffer) { + if (!archiveBuffer || !voiceArchiveBuffer) { if (lastError) { throw lastError; } throw new Error( - `Could not find release asset ${asset} for tag ${resolvedTag}` + + `Could not find release assets ${asset} and ${voiceAsset} for tag ${resolvedTag}` + (alternate && alternate !== resolvedTag ? ` or ${alternate}` : "") + "." ); } - log("Extracting StemCode CLI..."); - // Extract to a temp file first, then rename so concurrent runs never observe - // a partially written executable. - const tempPath = path.join( + fs.mkdirSync(platform.vendorDir(), { recursive: true }); + const tempBinaryPath = path.join( platform.vendorDir(), `.${platform.executableFileName()}.${process.pid}.tmp` ); - extractExecutable(archiveBuffer, tempPath); - fs.renameSync(tempPath, binaryPath); + const tempVoiceDir = path.join(platform.vendorDir(), `.voice.${process.pid}.tmp`); + + try { + log("Extracting StemCode CLI..."); + extractExecutable(archiveBuffer, tempBinaryPath); + + log("Extracting StemCode Voice runtime..."); + fs.rmSync(tempVoiceDir, { recursive: true, force: true }); + extractVoiceRuntime(voiceArchiveBuffer, tempVoiceDir); + + fs.rmSync(platform.voiceDir(), { recursive: true, force: true }); + fs.renameSync(tempVoiceDir, platform.voiceDir()); + fs.renameSync(tempBinaryPath, binaryPath); + } finally { + fs.rmSync(tempBinaryPath, { force: true }); + fs.rmSync(tempVoiceDir, { recursive: true, force: true }); + } log(`Installed StemCode CLI to ${binaryPath} (${usedTag}).`); + log(`Installed StemCode Voice runtime to ${platform.voiceDir()} (${usedTag}).`); // Fire once per genuine install. Updates pass force=true and are intentionally // excluded so they are not counted as new installs. From 1a583e0fd27e0aa2f356670bb4913cd57992fdc4 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:22:43 +0500 Subject: [PATCH 05/12] Install Voice runtime with shell installer --- scripts/install.sh | 58 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 7dde70b4..7f41510a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,9 @@ set -euo pipefail readonly OWNER="rizwan3d" readonly REPO="StemCode" readonly APP_NAME="StemCode.CLI" +readonly VOICE_APP_NAME="StemCode.Voice" readonly EXECUTABLE_NAME="StemCode.CLI" +readonly VOICE_EXECUTABLE_NAME="stemcode-voice" # Installed command name: '/update' sets StemCode_COMMAND_NAME so the running # binary's filename is preserved when replacing it in place. readonly COMMAND_NAME="${STEMCODE_COMMAND_NAME:-${StemCode_COMMAND_NAME:-stemcode}}" @@ -136,6 +138,16 @@ resolve_archive_executable_name() { fi } +resolve_voice_archive_executable_name() { + local platform="$1" + + if is_windows_platform "$platform"; then + printf '%s.exe\n' "$VOICE_EXECUTABLE_NAME" + else + printf '%s\n' "$VOICE_EXECUTABLE_NAME" + fi +} + resolve_destination_file_name() { local platform="$1" @@ -736,12 +748,19 @@ main() { local platform local install_dir local asset_name + local voice_asset_name local download_url + local voice_download_url local archive_path + local voice_archive_path local extract_dir + local voice_extract_dir local source_binary + local source_voice_binary local destination_binary + local voice_destination_dir local source_binary_name + local source_voice_binary_name local destination_file_name log "StemCode CLI Installer" @@ -765,49 +784,70 @@ main() { fi asset_name="${APP_NAME}-${platform}.zip" + voice_asset_name="${VOICE_APP_NAME}-${platform}.zip" download_url="https://github.com/${OWNER}/${REPO}/releases/download/${tag}/${asset_name}" + voice_download_url="https://github.com/${OWNER}/${REPO}/releases/download/${tag}/${voice_asset_name}" source_binary_name="$(resolve_archive_executable_name "$platform")" + source_voice_binary_name="$(resolve_voice_archive_executable_name "$platform")" destination_file_name="$(resolve_destination_file_name "$platform")" - finish_step "Using ${APP_NAME} ${tag} for ${platform}." + finish_step "Using ${APP_NAME} and ${VOICE_APP_NAME} ${tag} for ${platform}." start_step "Preparing install directory..." log "Install directory: ${install_dir}" TEMP_ROOT="$(mktemp -d)" archive_path="${TEMP_ROOT}/${asset_name}" + voice_archive_path="${TEMP_ROOT}/${voice_asset_name}" extract_dir="${TEMP_ROOT}/extract" + voice_extract_dir="${TEMP_ROOT}/voice-extract" - mkdir -p "$extract_dir" "$install_dir" + mkdir -p "$extract_dir" "$voice_extract_dir" "$install_dir" finish_step "Workspace ready." - start_step "Downloading ${asset_name}..." + start_step "Downloading release assets..." if ! download_to_file "$download_url" "$archive_path" 1; then fail "Download failed from ${download_url}." fi - finish_step "Downloaded $(format_bytes "$(file_size "$archive_path")")." + if ! download_to_file "$voice_download_url" "$voice_archive_path" 1; then + fail "Download failed from ${voice_download_url}." + fi + finish_step "Downloaded ${asset_name} ($(format_bytes "$(file_size "$archive_path")")) and ${voice_asset_name} ($(format_bytes "$(file_size "$voice_archive_path")"))." - start_step "Verifying download..." + start_step "Verifying downloads..." verify_archive_sha256 "$tag" "$asset_name" "$archive_path" + verify_archive_sha256 "$tag" "$voice_asset_name" "$voice_archive_path" finish_step "Checksum verification passed." - start_step "Extracting archive..." + start_step "Extracting archives..." unzip -qo "$archive_path" -d "$extract_dir" + unzip -qo "$voice_archive_path" -d "$voice_extract_dir" source_binary="$(find "$extract_dir" -type f -name "$source_binary_name" | head -n 1)" + source_voice_binary="$(find "$voice_extract_dir" -type f -name "$source_voice_binary_name" | head -n 1)" if [[ -z "$source_binary" || ! -f "$source_binary" ]]; then fail "Expected executable '${source_binary_name}' was not found in ${asset_name}." fi - finish_step "Found ${source_binary_name}." + if [[ -z "$source_voice_binary" || ! -f "$source_voice_binary" ]]; then + fail "Expected executable '${source_voice_binary_name}' was not found in ${voice_asset_name}." + fi + finish_step "Found ${source_binary_name} and ${source_voice_binary_name}." - start_step "Installing command..." + start_step "Installing command and Voice runtime..." destination_binary="${install_dir}/${destination_file_name}" + voice_destination_dir="${install_dir}/voice" + cp "$source_binary" "$destination_binary" + rm -rf "$voice_destination_dir" + mkdir -p "$voice_destination_dir" + cp -R "${voice_extract_dir}/." "$voice_destination_dir/" + if ! is_windows_platform "$platform"; then chmod 0755 "$destination_binary" + chmod 0755 "${voice_destination_dir}/${source_voice_binary_name}" fi - finish_step "Installed '${COMMAND_NAME}' to ${destination_binary}." + finish_step "Installed '${COMMAND_NAME}' to ${destination_binary} and Voice runtime to ${voice_destination_dir}." send_install_telemetry "$platform" "$tag" From fa347448535bdffa0dd6edbae70f1045371831ea Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:23:49 +0500 Subject: [PATCH 06/12] Install Voice runtime with PowerShell installer --- scripts/install.ps1 | 61 ++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 68549446..3652c048 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -35,8 +35,11 @@ if ([string]::IsNullOrWhiteSpace($CommandName)) { $Owner = 'rizwan3d' $Repo = 'StemCode' $AppName = 'StemCode.CLI' +$VoiceAppName = 'StemCode.Voice' $ExecutableName = 'StemCode.CLI' +$VoiceExecutableName = 'stemcode-voice' $AssetName = "$ExecutableName-win-x64.zip" +$VoiceAssetName = "$VoiceAppName-win-x64.zip" $ChecksumsName = 'SHA256SUMS' $TotalSteps = 7 $CurrentStep = 0 @@ -348,7 +351,10 @@ function Test-ArchiveSha256 { [string]$ArchivePath, [Parameter(Mandatory = $true)] - [string]$TempRoot + [string]$TempRoot, + + [Parameter(Mandatory = $true)] + [string]$FileName ) $checksumsUrl = "https://github.com/$Owner/$Repo/releases/download/$Tag/$ChecksumsName" @@ -360,33 +366,33 @@ function Test-ArchiveSha256 { Save-UrlToFile -Url $checksumsUrl -Path $checksumsPath } catch { - $expectedSha256 = Get-ReleaseAssetSha256 -Tag $Tag -FileName $AssetName + $expectedSha256 = Get-ReleaseAssetSha256 -Tag $Tag -FileName $FileName if ([string]::IsNullOrWhiteSpace($expectedSha256)) { Fail-Install "Unable to download $ChecksumsName from $checksumsUrl, and no GitHub release metadata digest was found. Checksum verification is mandatory. $($_.Exception.Message)" } - Write-Status "Using SHA256 digest from GitHub release metadata for $AssetName." + Write-Status "Using SHA256 digest from GitHub release metadata for $FileName." } if ([string]::IsNullOrWhiteSpace($expectedSha256)) { - $expectedSha256 = Get-ExpectedSha256 -ChecksumsPath $checksumsPath -FileName $AssetName + $expectedSha256 = Get-ExpectedSha256 -ChecksumsPath $checksumsPath -FileName $FileName } if ([string]::IsNullOrWhiteSpace($expectedSha256)) { - Fail-Install "$ChecksumsName does not contain a checksum for $AssetName." + Fail-Install "$ChecksumsName does not contain a checksum for $FileName." } if ($expectedSha256 -notmatch '^[0-9a-f]{64}$') { - Fail-Install "$ChecksumsName contains an invalid SHA256 checksum for $AssetName." + Fail-Install "$ChecksumsName contains an invalid SHA256 checksum for $FileName." } $actualSha256 = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { - Fail-Install "SHA256 verification failed for $AssetName. Expected $expectedSha256, got $actualSha256." + Fail-Install "SHA256 verification failed for $FileName. Expected $expectedSha256, got $actualSha256." } - Write-Status "Verified SHA256 checksum for $AssetName." + Write-Status "Verified SHA256 checksum for $FileName." } function Test-PathContainsDirectory { @@ -651,13 +657,17 @@ if ([string]::IsNullOrWhiteSpace($Tag)) { $Tag = Get-LatestTag } -Complete-InstallStep "Using $AppName $Tag for win-x64." +Complete-InstallStep "Using $AppName and $VoiceAppName $Tag for win-x64." $downloadUrl = "https://github.com/$Owner/$Repo/releases/download/$Tag/$AssetName" +$voiceDownloadUrl = "https://github.com/$Owner/$Repo/releases/download/$Tag/$VoiceAssetName" $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "$AppName-install-$([System.Guid]::NewGuid().ToString('N'))" $archivePath = Join-Path $tempRoot $AssetName +$voiceArchivePath = Join-Path $tempRoot $VoiceAssetName $extractDir = Join-Path $tempRoot 'extract' +$voiceExtractDir = Join-Path $tempRoot 'voice-extract' $destinationPath = Join-Path $InstallDir "$CommandName.exe" +$voiceDestinationDir = Join-Path $InstallDir 'voice' $cleanupTempRoot = $true try { @@ -666,35 +676,50 @@ try { Start-InstallStep 'Preparing install directory...' Write-Status "Install directory: $InstallDir" - New-Item -ItemType Directory -Path $tempRoot, $extractDir, $InstallDir -Force | Out-Null + New-Item -ItemType Directory -Path $tempRoot, $extractDir, $voiceExtractDir, $InstallDir -Force | Out-Null Complete-InstallStep 'Workspace ready.' - Start-InstallStep "Downloading $AssetName..." + Start-InstallStep 'Downloading release assets...' try { Save-UrlToFile -Url $downloadUrl -Path $archivePath -Activity "Downloading $AssetName" -ShowProgress + Save-UrlToFile -Url $voiceDownloadUrl -Path $voiceArchivePath -Activity "Downloading $VoiceAssetName" -ShowProgress } catch { - Fail-Install "Download failed from $downloadUrl. $($_.Exception.Message)" + Fail-Install "Download failed. $($_.Exception.Message)" } $downloadedSize = (Get-Item -LiteralPath $archivePath).Length - Complete-InstallStep "Downloaded $AssetName ($(Format-ByteSize -Bytes $downloadedSize))." + $voiceDownloadedSize = (Get-Item -LiteralPath $voiceArchivePath).Length + Complete-InstallStep "Downloaded $AssetName ($(Format-ByteSize -Bytes $downloadedSize)) and $VoiceAssetName ($(Format-ByteSize -Bytes $voiceDownloadedSize))." - Start-InstallStep 'Verifying download...' - Test-ArchiveSha256 -Tag $Tag -ArchivePath $archivePath -TempRoot $tempRoot + Start-InstallStep 'Verifying downloads...' + Test-ArchiveSha256 -Tag $Tag -ArchivePath $archivePath -TempRoot $tempRoot -FileName $AssetName + Test-ArchiveSha256 -Tag $Tag -ArchivePath $voiceArchivePath -TempRoot $tempRoot -FileName $VoiceAssetName Complete-InstallStep 'Checksum verification passed.' - Start-InstallStep 'Extracting archive...' + Start-InstallStep 'Extracting archives...' Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force + Expand-Archive -Path $voiceArchivePath -DestinationPath $voiceExtractDir -Force $sourcePath = Join-Path $extractDir "$ExecutableName.exe" + $voiceSourcePath = Join-Path $voiceExtractDir "$VoiceExecutableName.exe" if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { Fail-Install "Expected executable '$ExecutableName.exe' was not found in $AssetName." } + if (-not (Test-Path -LiteralPath $voiceSourcePath -PathType Leaf)) { + Fail-Install "Expected executable '$VoiceExecutableName.exe' was not found in $VoiceAssetName." + } + + Complete-InstallStep "Found $ExecutableName.exe and $VoiceExecutableName.exe." - Complete-InstallStep "Found $ExecutableName.exe." + Start-InstallStep 'Installing command and Voice runtime...' + if (Test-Path -LiteralPath $voiceDestinationDir) { + Remove-Item -LiteralPath $voiceDestinationDir -Recurse -Force + } + New-Item -ItemType Directory -Path $voiceDestinationDir -Force | Out-Null + Get-ChildItem -LiteralPath $voiceExtractDir -Force | Copy-Item -Destination $voiceDestinationDir -Recurse -Force + Write-Status "Installed Voice runtime to $voiceDestinationDir" - Start-InstallStep 'Installing command...' $waitProcessId = Resolve-WaitForProcessId -RequestedProcessId $WaitForProcessId -DestinationPath $destinationPath if ($waitProcessId -gt 0) { Start-DeferredInstall -SourcePath $sourcePath -DestinationPath $destinationPath -ProcessId $waitProcessId -CleanupRoot $tempRoot From 44c9dfc37d287d14fe5955fa8c30b99bd5c3fa0c Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:24:29 +0500 Subject: [PATCH 07/12] Test npm Voice runtime installation --- packaging/npm/tests/download.test.js | 77 +++++++++++++++++++++------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/packaging/npm/tests/download.test.js b/packaging/npm/tests/download.test.js index 742d4741..919e4722 100644 --- a/packaging/npm/tests/download.test.js +++ b/packaging/npm/tests/download.test.js @@ -14,10 +14,12 @@ function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex").toLowerCase(); } -function makeZip(assetName, contents) { +function makeZip(entries) { const AdmZip = require("adm-zip"); const zip = new AdmZip(); - zip.addFile(assetName, Buffer.from(contents)); + for (const [name, contents] of Object.entries(entries)) { + zip.addFile(name, Buffer.from(contents)); + } return zip.toBuffer(); } @@ -33,9 +35,9 @@ function fakeResponse(body, status = 200) { }; } -// Serves a zip (whose checksum is embedded in the SHA256SUMS response) for each -// tag in `zipByTag`, and returns HTTP 404 for tags in `missingTags`. -function makeFetch({ missingTags, zipByTag, asset }) { +// Serves release assets for each tag and emits checksums for every prepared +// archive so the CLI and Voice runtime are verified from the same release. +function makeFetch({ missingTags, assetsByTag }) { return async (url) => { const match = url.match(/\/releases\/download\/([^/]+)\/(.+)$/); assert.ok(match, `unexpected request url: ${url}`); @@ -45,13 +47,18 @@ function makeFetch({ missingTags, zipByTag, asset }) { return fakeResponse("", 404); } - const zipBuffer = zipByTag.get(tag); - assert.ok(zipBuffer, `no prepared zip for tag ${tag}`); + const assets = assetsByTag.get(tag); + assert.ok(assets, `no prepared assets for tag ${tag}`); if (file === platform.CHECKSUMS_NAME) { - return fakeResponse(`${sha256(zipBuffer)} ${asset}\n`); + const checksums = [...assets.entries()] + .map(([name, buffer]) => `${sha256(buffer)} ${name}`) + .join("\n"); + return fakeResponse(`${checksums}\n`); } - return fakeResponse(zipBuffer); + + const archive = assets.get(file); + return archive ? fakeResponse(archive) : fakeResponse("", 404); }; } @@ -59,13 +66,17 @@ async function withDownloadOverrides(overrides, fn) { const originals = { fetch: global.fetch, installedBinaryPath: platform.installedBinaryPath, + installedVoiceBinaryPath: platform.installedVoiceBinaryPath, vendorDir: platform.vendorDir, + voiceDir: platform.voiceDir, cliVersion: process.env.STEMCODE_CLI_VERSION, }; try { global.fetch = overrides.fetch; platform.installedBinaryPath = overrides.installedBinaryPath; + platform.installedVoiceBinaryPath = overrides.installedVoiceBinaryPath; platform.vendorDir = overrides.vendorDir; + platform.voiceDir = overrides.voiceDir; if (overrides.cliVersion === undefined) { delete process.env.STEMCODE_CLI_VERSION; } else { @@ -75,7 +86,9 @@ async function withDownloadOverrides(overrides, fn) { } finally { global.fetch = originals.fetch; platform.installedBinaryPath = originals.installedBinaryPath; + platform.installedVoiceBinaryPath = originals.installedVoiceBinaryPath; platform.vendorDir = originals.vendorDir; + platform.voiceDir = originals.voiceDir; if (originals.cliVersion === undefined) { delete process.env.STEMCODE_CLI_VERSION; } else { @@ -84,61 +97,87 @@ async function withDownloadOverrides(overrides, fn) { } } +function createReleaseAssets(tag, cliContents, voiceContents) { + const rid = platform.resolveRid(); + const cliAsset = platform.assetName(rid); + const voiceAsset = platform.voiceAssetName(rid); + const cliZip = makeZip({ + [platform.executableFileName()]: cliContents, + }); + const voiceZip = makeZip({ + [platform.voiceExecutableFileName()]: voiceContents, + "voice-native-file.txt": "native-runtime-content", + }); + + return new Map([ + [tag, new Map([ + [cliAsset, cliZip], + [voiceAsset, voiceZip], + ])], + ]); +} + test("ensureBinary falls back to the alternate-case tag when the primary 404s", async () => { - const asset = platform.assetName(platform.resolveRid()); const executable = platform.executableFileName(); + const voiceExecutable = platform.voiceExecutableFileName(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "stemcode-dl-")); + const voiceDir = path.join(tempDir, "voice"); try { - const zipBuffer = makeZip(executable, "fake-stemcode-binary"); const fetch = makeFetch({ missingTags: new Set(["V1.1.10"]), - zipByTag: new Map([["v1.1.10", zipBuffer]]), - asset, + assetsByTag: createReleaseAssets("v1.1.10", "fake-stemcode-binary", "fake-voice-binary"), }); const result = await withDownloadOverrides( { fetch, installedBinaryPath: () => path.join(tempDir, executable), + installedVoiceBinaryPath: () => path.join(voiceDir, voiceExecutable), vendorDir: () => tempDir, + voiceDir: () => voiceDir, cliVersion: "1.1.10", // resolveTag() => V1.1.10 (primary) }, () => download.ensureBinary({ force: true, log: () => {} }) ); assert.equal(result, path.join(tempDir, executable)); - assert.ok(fs.existsSync(result), "binary should be extracted to disk"); + assert.ok(fs.existsSync(result), "CLI binary should be extracted to disk"); + assert.ok(fs.existsSync(path.join(voiceDir, voiceExecutable)), "Voice runtime should be extracted"); + assert.ok(fs.existsSync(path.join(voiceDir, "voice-native-file.txt")), "Voice native files should be retained"); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); test("ensureBinary uses the primary tag when it resolves successfully", async () => { - const asset = platform.assetName(platform.resolveRid()); const executable = platform.executableFileName(); + const voiceExecutable = platform.voiceExecutableFileName(); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "stemcode-dl-")); + const voiceDir = path.join(tempDir, "voice"); try { - const zipBuffer = makeZip(executable, "fake-stemcode-binary-primary"); const fetch = makeFetch({ missingTags: new Set(), - zipByTag: new Map([["V1.1.10", zipBuffer]]), - asset, + assetsByTag: createReleaseAssets("V1.1.10", "fake-stemcode-binary-primary", "fake-voice-binary-primary"), }); const result = await withDownloadOverrides( { fetch, installedBinaryPath: () => path.join(tempDir, executable), + installedVoiceBinaryPath: () => path.join(voiceDir, voiceExecutable), vendorDir: () => tempDir, + voiceDir: () => voiceDir, cliVersion: "1.1.10", // resolveTag() => V1.1.10 (primary) }, () => download.ensureBinary({ force: true, log: () => {} }) ); assert.equal(result, path.join(tempDir, executable)); - assert.ok(fs.existsSync(result), "binary should be extracted to disk"); + assert.ok(fs.existsSync(result), "CLI binary should be extracted to disk"); + assert.ok(fs.existsSync(path.join(voiceDir, voiceExecutable)), "Voice runtime should be extracted"); + assert.ok(fs.existsSync(path.join(voiceDir, "voice-native-file.txt")), "Voice native files should be retained"); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } From 04767c18ce7475abf7fb8fd747d77677ab21f345 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:39:47 +0500 Subject: [PATCH 08/12] Synchronize Voice runtime during CLI updates --- .../Updates/GitHubApplicationUpdateService.cs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/StemCode/Infrastructure/Updates/GitHubApplicationUpdateService.cs b/StemCode/Infrastructure/Updates/GitHubApplicationUpdateService.cs index 539d29e4..f7c21ccd 100644 --- a/StemCode/Infrastructure/Updates/GitHubApplicationUpdateService.cs +++ b/StemCode/Infrastructure/Updates/GitHubApplicationUpdateService.cs @@ -60,13 +60,10 @@ public async Task 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? onOutputLine = progress is null ? null : line => @@ -83,9 +80,10 @@ public async Task 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, @@ -101,8 +99,8 @@ public async Task 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( From 83b7273244a3b5ded5c69454929e162e4ddb98b9 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:40:02 +0500 Subject: [PATCH 09/12] Sync Voice runtime with update now --- .../ReplCommands/UpdateCommandHandler.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/StemCode/Application/Commands/ReplCommands/UpdateCommandHandler.cs b/StemCode/Application/Commands/ReplCommands/UpdateCommandHandler.cs index 3c18d122..1751f6d9 100644 --- a/StemCode/Application/Commands/ReplCommands/UpdateCommandHandler.cs +++ b/StemCode/Application/Commands/ReplCommands/UpdateCommandHandler.cs @@ -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]"; @@ -60,19 +60,22 @@ public async Task 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); @@ -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; From 5ccac857ab88f3bf1506b4a988d6d155d1429857 Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:40:52 +0500 Subject: [PATCH 10/12] Update Voice runtime with npm CLI updates --- packaging/npm/scripts/update.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packaging/npm/scripts/update.js b/packaging/npm/scripts/update.js index 93065f6b..cae60c16 100644 --- a/packaging/npm/scripts/update.js +++ b/packaging/npm/scripts/update.js @@ -161,17 +161,17 @@ async function promptForUpdate(currentVersion, latestVersion) { const { default: select } = await import("@inquirer/select"); return await select({ - message: `StemCode ${latestVersion} is available. Update before launch?`, + message: `StemCode ${latestVersion} is available. Update CLI and Voice runtime before launch?`, choices: [ { name: `Yes, update from ${currentVersion} to ${latestVersion}`, value: true, - description: "Downloads the latest StemCode CLI binary, then starts stemcode.", + description: "Downloads the latest StemCode CLI and matching Voice runtime, then starts stemcode.", }, { name: `No, continue with ${currentVersion}`, value: false, - description: "Skip this update check and launch the currently installed binary.", + description: "Skip this update check and launch the currently installed CLI and Voice runtime.", }, ], default: false, @@ -217,24 +217,32 @@ async function maybeUpdateBinary(binaryPath, options = {}) { return binaryPath; } - // Before replacing the vendored binary, terminate any other running StemCode - // sessions so the file is not held by another process during the update. + // Before replacing the vendored release, terminate any other running StemCode + // sessions so neither the CLI nor Voice runtime is held by another process. await terminateOtherInstances({ log }); - log(`Updating StemCode CLI to ${latestRelease.tag}...`); + log(`Updating StemCode CLI and Voice runtime to ${latestRelease.tag}...`); try { - return await ensureBinary({ + const updatedBinaryPath = await ensureBinary({ force: true, tag: latestRelease.tag, log, }); + + // ensureBinary installs the two release archives as one unit. Keep an explicit + // guard here so the npm updater never reports success with a CLI-only install. + if (!fs.existsSync(platform.installedVoiceBinaryPath())) { + throw new Error("StemCode Voice runtime was not installed with the CLI update."); + } + + return updatedBinaryPath; } catch (error) { const message = error && error.message ? error.message : String(error); log(`Update failed: ${message}`); - log("Starting the currently installed StemCode CLI instead."); + log("Starting the currently installed StemCode CLI and Voice runtime instead."); return binaryPath; } } From dea609042434f40642f7b0e152bc01bee2761c0d Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:41:07 +0500 Subject: [PATCH 11/12] Test Voice synchronization during CLI update --- .../GitHubApplicationUpdateVoiceSyncTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 StemCode.Tests/Infrastructure/Updates/GitHubApplicationUpdateVoiceSyncTests.cs diff --git a/StemCode.Tests/Infrastructure/Updates/GitHubApplicationUpdateVoiceSyncTests.cs b/StemCode.Tests/Infrastructure/Updates/GitHubApplicationUpdateVoiceSyncTests.cs new file mode 100644 index 00000000..37f4bb80 --- /dev/null +++ b/StemCode.Tests/Infrastructure/Updates/GitHubApplicationUpdateVoiceSyncTests.cs @@ -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"); + } +} From fbd207628fc30347d0d270af523433505727a16f Mon Sep 17 00:00:00 2001 From: Muhammad Rizwan Date: Sun, 16 Aug 2026 13:41:18 +0500 Subject: [PATCH 12/12] Test update now synchronizes Voice runtime --- .../UpdateVoiceSynchronizationCommandTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 StemCode.Tests/Application/Commands/UpdateVoiceSynchronizationCommandTests.cs diff --git a/StemCode.Tests/Application/Commands/UpdateVoiceSynchronizationCommandTests.cs b/StemCode.Tests/Application/Commands/UpdateVoiceSynchronizationCommandTests.cs new file mode 100644 index 00000000..acd3e259 --- /dev/null +++ b/StemCode.Tests/Application/Commands/UpdateVoiceSynchronizationCommandTests.cs @@ -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 updateService = new(MockBehavior.Strict); + updateService + .Setup(service => service.CheckAsync(It.IsAny())) + .ReturnsAsync(updateInfo); + updateService + .Setup(service => service.InstallAsync( + updateInfo, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(installResult); + + Mock confirmationPrompt = new(MockBehavior.Strict); + Mock instanceService = new(MockBehavior.Strict); + instanceService + .Setup(service => service.GetOtherRunningInstancesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + Mock 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(message => message.Contains("Synchronizing") && message.Contains("Voice runtime")), + It.IsAny()), + 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); + } +}