diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index c211663474f..d58dd17aeb8 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -29,6 +29,10 @@ inputs: description: 'Set to true if the branch is already pushed remotely (skips commit/push)' required: false default: 'false' + draft: + description: 'Create the pull request as a draft' + required: false + default: 'false' outputs: pull-request-number: description: 'The pull request number' @@ -91,6 +95,7 @@ runs: PR_TITLE: ${{ inputs.title }} PR_BODY: ${{ inputs.body }} LABELS: ${{ inputs.labels }} + DRAFT: ${{ inputs.draft }} run: | # Check if a PR already exists for this branch EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --json number,url --jq '.[0] // empty') @@ -133,12 +138,18 @@ runs: trap 'rm -f "$BODY_FILE"' EXIT printf '%s\n' "$PR_BODY" > "$BODY_FILE" + DRAFT_ARGS=() + if [ "$DRAFT" = "true" ]; then + DRAFT_ARGS+=(--draft) + fi + # Create the pull request without eval — all args are properly quoted PR_URL=$(gh pr create \ --title "$PR_TITLE" \ --body-file "$BODY_FILE" \ --base "$BASE" \ --head "$BRANCH" \ + "${DRAFT_ARGS[@]}" \ "${LABEL_ARGS[@]}") rm -f "$BODY_FILE" diff --git a/.github/workflows/update-aspire-skills-bundle.yml b/.github/workflows/update-aspire-skills-bundle.yml new file mode 100644 index 00000000000..ac28ca9dad8 --- /dev/null +++ b/.github/workflows/update-aspire-skills-bundle.yml @@ -0,0 +1,77 @@ +name: Update Aspire Skills Bundle + +on: + workflow_dispatch: + inputs: + version: + description: "Aspire skills release version to embed. Defaults to the currently pinned version." + required: false + type: string + schedule: + - cron: '0 17 * * *' # 9am PT / 17:00 UTC + +permissions: + contents: write + pull-requests: write + +jobs: + update-and-pr: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate GitHub App Token for bundle update + id: update-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Update embedded Aspire skills bundle + shell: pwsh + env: + GH_TOKEN: ${{ steps.update-token.outputs.token }} + VERSION: ${{ inputs.version }} + run: | + if ([string]::IsNullOrWhiteSpace($env:VERSION)) { + ./eng/scripts/update-aspire-skills-bundle.ps1 + } + else { + ./eng/scripts/update-aspire-skills-bundle.ps1 -Version $env:VERSION + } + + - name: Restore solution + run: ./restore.sh + + - name: Test Aspire skills installer + run: > + dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj + --no-launch-profile + -- + --filter-class "*.AspireSkillsInstallerTests" + --filter-class "*.AspireSkillsBundleTests" + --filter-class "*.AgentInitCommandTests" + --filter-not-trait "quarantined=true" + --filter-not-trait "outerloop=true" + + - name: Generate GitHub App Token for pull request + id: pr-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Create or update pull request + uses: ./.github/actions/create-pull-request + with: + token: ${{ steps.pr-token.outputs.token }} + branch: update-aspire-skills-bundle + base: main + draft: true + commit-message: "[Automated] Update Aspire skills bundle" + labels: | + area-cli + area-engineering-systems + title: "[Automated] Update Aspire skills bundle" + body: "Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI." diff --git a/.github/workflows/verify-aspire-skills-bundle.yml b/.github/workflows/verify-aspire-skills-bundle.yml new file mode 100644 index 00000000000..db82170a3dc --- /dev/null +++ b/.github/workflows/verify-aspire-skills-bundle.yml @@ -0,0 +1,26 @@ +name: Verify Aspire Skills Bundle + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/Aspire.Cli/Agents/AspireSkills/Embedded/**' + - 'eng/scripts/verify-aspire-skills-bundle.ps1' + - '.github/workflows/verify-aspire-skills-bundle.yml' + +permissions: + contents: read + attestations: read + +jobs: + verify: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify embedded Aspire skills bundle attestation + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./eng/scripts/verify-aspire-skills-bundle.ps1 diff --git a/eng/scripts/update-aspire-skills-bundle.ps1 b/eng/scripts/update-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..94c2212c641 --- /dev/null +++ b/eng/scripts/update-aspire-skills-bundle.ps1 @@ -0,0 +1,164 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Version, + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' +$installerPath = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\AspireSkillsInstaller.cs' +$cliProjectPath = Join-Path $repoRoot 'src\Aspire.Cli\Aspire.Cli.csproj' + +function Invoke-GitHubCli { + param( + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "gh $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Get-UnprefixedVersion([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { + throw 'A version is required.' + } + + if ($Value.StartsWith('v', [System.StringComparison]::OrdinalIgnoreCase)) { + return $Value.Substring(1) + } + + return $Value +} + +function Get-CurrentEmbeddedVersion { + if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'. Pass -Version to choose the initial version." + } + + $metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + return Get-UnprefixedVersion $metadata.version +} + +function Set-TextFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Content.TrimEnd("`r", "`n") + [System.Environment]::NewLine, $utf8NoBom) +} + +function Get-GitHubRelease([string]$NormalizedVersion) { + $tagCandidates = @("v$NormalizedVersion", $NormalizedVersion) | Select-Object -Unique + + foreach ($tag in $tagCandidates) { + try { + $json = Invoke-GitHubCli release view $tag --repo $Repository --json 'tagName,assets' + return $json | ConvertFrom-Json + } + catch { + Write-Host "Release '$tag' was not found in '$Repository'." + } + } + + throw "Could not find an Aspire skills release for version '$NormalizedVersion' in '$Repository'." +} + +function Get-ReleaseAsset($Release, [string]$NormalizedVersion) { + $assetNameCandidates = foreach ($archiveExtension in @('.zip', '.tar.gz', '.tgz')) { + "aspire-skills-v$NormalizedVersion$archiveExtension" + "aspire-skills-$NormalizedVersion$archiveExtension" + } + + foreach ($assetName in $assetNameCandidates) { + $asset = $Release.assets | Where-Object { $_.name -ieq $assetName } | Select-Object -First 1 + if ($null -ne $asset) { + return $asset + } + } + + throw "Release '$($Release.tagName)' does not contain a supported Aspire skills archive asset for version '$NormalizedVersion'." +} + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to update the embedded Aspire skills bundle." +} + +$normalizedVersion = if ([string]::IsNullOrWhiteSpace($Version)) { + Get-CurrentEmbeddedVersion +} +else { + Get-UnprefixedVersion $Version +} + +New-Item -ItemType Directory -Force -Path $embeddedDir | Out-Null + +Write-Host "Resolving Aspire skills release '$normalizedVersion' from '$Repository'..." +$release = Get-GitHubRelease $normalizedVersion +$asset = Get-ReleaseAsset $release $normalizedVersion + +$tempDir = [System.IO.Directory]::CreateTempSubdirectory('aspire-skills-update-').FullName +try { + Write-Host "Downloading '$($asset.name)' from '$Repository' release '$($release.tagName)'..." + Invoke-GitHubCli release download $release.tagName --repo $Repository --pattern $asset.name --dir $tempDir --clobber + + $archivePath = Join-Path $tempDir $asset.name + if (-not (Test-Path $archivePath)) { + throw "Expected downloaded asset '$archivePath' was not found." + } + + $certIdentity = "https://github.com/$Repository/.github/workflows/publish.yml@refs/tags/$($release.tagName)" + Write-Host "Verifying GitHub artifact attestation for '$($asset.name)'..." + Invoke-GitHubCli attestation verify $archivePath --repo $Repository --cert-identity $certIdentity --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + + $hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + $targetArchivePath = Join-Path $embeddedDir $asset.name + + Get-ChildItem -Path $embeddedDir -File -Force | + Where-Object { $_.Name -match '^aspire-skills-.*\.(zip|tar\.gz|tgz)$' -and $_.Name -ne $asset.name } | + Remove-Item -Force + + Copy-Item -Path $archivePath -Destination $targetArchivePath -Force + + $metadata = [ordered]@{ + version = $normalizedVersion + repository = $Repository + tag = $release.tagName + assetName = $asset.name + sha256 = $hash + } + Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json) + + $installerContent = Get-Content -Raw -Path $installerPath + $installerContent = [regex]::Replace( + $installerContent, + 'internal const string Version = "[^"]+";', + "internal const string Version = ""$normalizedVersion"";") + Set-TextFile -Path $installerPath -Content $installerContent + + $cliProjectContent = Get-Content -Raw -Path $cliProjectPath + $cliProjectContent = [regex]::Replace( + $cliProjectContent, + 'Agents\\AspireSkills\\Embedded\\aspire-skills-[^"]+\.(zip|tar\.gz|tgz)', + "Agents\AspireSkills\Embedded\$($asset.name)") + Set-TextFile -Path $cliProjectPath -Content $cliProjectContent + + Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-256 '$hash'." +} +finally { + if (Test-Path $tempDir) { + Remove-Item -Path $tempDir -Recurse -Force + } +} diff --git a/eng/scripts/verify-aspire-skills-bundle.ps1 b/eng/scripts/verify-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..550a5e34b03 --- /dev/null +++ b/eng/scripts/verify-aspire-skills-bundle.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to verify the embedded Aspire skills bundle." +} + +if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'." +} + +$metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + +if ($metadata.repository -ne $Repository) { + throw "Unexpected embedded bundle repository '$($metadata.repository)'. Expected '$Repository'." +} + +if ([string]::IsNullOrWhiteSpace($metadata.tag)) { + throw "Embedded Aspire skills metadata must specify a GitHub release tag." +} + +if ([string]::IsNullOrWhiteSpace($metadata.assetName)) { + throw "Embedded Aspire skills metadata must specify a release asset name." +} + +if ($metadata.assetName -ne [System.IO.Path]::GetFileName($metadata.assetName)) { + throw "Embedded Aspire skills asset name '$($metadata.assetName)' must not contain path separators." +} + +if ([string]::IsNullOrWhiteSpace($metadata.sha256)) { + throw "Embedded Aspire skills metadata must specify the release asset SHA-256 hash." +} + +$archivePath = Join-Path $embeddedDir $metadata.assetName +if (-not (Test-Path $archivePath)) { + throw "Embedded Aspire skills archive was not found at '$archivePath'." +} + +$actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() +if ($actualHash -ne $metadata.sha256) { + throw "Embedded bundle SHA-256 mismatch. Expected '$($metadata.sha256)', got '$actualHash'." +} + +$certIdentity = "https://github.com/$($metadata.repository)/.github/workflows/publish.yml@refs/tags/$($metadata.tag)" +gh attestation verify $archivePath ` + --repo $metadata.repository ` + --cert-identity $certIdentity ` + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + +Write-Host "Embedded Aspire skills bundle '$($metadata.assetName)' verified against GitHub artifact attestation." diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index dc47c324cce..49c726ea960 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -302,7 +302,7 @@ internal static string NormalizeRelativePath(string? relativePath) return Path.Combine(segments); } - private static string NormalizeSha256(string sha256) + internal static string NormalizeSha256(string sha256) { const string prefix = "sha256-"; return sha256.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index dbc2f1cfdd5..dec2cca8e41 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO.Compression; using System.Net; +using System.Security.Cryptography; using System.Text.Json; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; @@ -21,6 +22,7 @@ namespace Aspire.Cli.Agents.AspireSkills; internal sealed class AspireSkillsInstaller( IGitHubArtifactAttestationVerifier githubArtifactAttestationVerifier, IHttpClientFactory httpClientFactory, + IEmbeddedAspireSkillsBundleProvider embeddedBundleProvider, IInteractionService interactionService, CliExecutionContext executionContext, IConfiguration configuration, @@ -46,7 +48,6 @@ public Task InstallAsync(CancellationToken cancellati AgentCommandStrings.AspireSkillsInstaller_InstallingStatus, () => InstallCoreAsync(cancellationToken)); } - private async Task InstallCoreAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartReportedActivity("AspireSkillsInstaller.Install"); @@ -80,12 +81,24 @@ private async Task InstallCoreAsync(CancellationToken if (githubResult.Status == AcquisitionStatus.Failed) { - activity?.SetStatus(ActivityStatusCode.Error, githubResult.Message); - return AspireSkillsInstallResult.Failed(githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_InvalidBundle); + logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + } + + var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); + if (embeddedResult.Status == AcquisitionStatus.Installed) + { + CleanupStaleCacheEntries(cacheRoot, effectiveVersion); + return AspireSkillsInstallResult.Installed(embeddedResult.Bundle!); } - activity?.SetStatus(ActivityStatusCode.Error, "GitHub acquisition is unavailable."); - return AspireSkillsInstallResult.Failed(AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable); + var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed + ? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : githubResult.Status == AcquisitionStatus.Failed + ? githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable; + + activity?.SetStatus(ActivityStatusCode.Error, failureMessage); + return AspireSkillsInstallResult.Failed(failureMessage); } private async Task InstallFromGitHubAsync( @@ -167,6 +180,127 @@ private async Task InstallFromGitHubAsync( } } + private async Task InstallFromEmbeddedAsync( + string cacheRoot, + string version, + Activity? activity, + CancellationToken cancellationToken) + { + var metadata = embeddedBundleProvider.Metadata; + if (metadata is null) + { + logger.LogDebug("No embedded Aspire skills bundle metadata is available."); + return AcquisitionResult.Unavailable(); + } + + if (ValidateEmbeddedMetadata(metadata) is { } metadataError) + { + return AcquisitionResult.Failed($"Embedded Aspire skills bundle metadata is invalid: {metadataError}"); + } + + if (!string.Equals(metadata.Version, version, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Embedded Aspire skills bundle version {EmbeddedVersion} does not match requested version {Version}.", + metadata.Version, + version); + return AcquisitionResult.Unavailable(); + } + + var tempDir = Path.Combine(cacheRoot, $".embedded-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var archivePath = Path.Combine(tempDir, GetSafeFileName(metadata.AssetName!)); + var archiveStream = embeddedBundleProvider.OpenArchive(); + if (archiveStream is null) + { + logger.LogDebug("Embedded Aspire skills archive is unavailable for version {Version}.", version); + return AcquisitionResult.Unavailable(); + } + + await using (archiveStream) + { + await using var fileStream = File.Create(archivePath); + await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + ValidateArchiveSha256(archivePath, metadata.Sha256!); + + try + { + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + activity?.SetTag("aspire.skills.source", "embedded"); + activity?.SetTag("aspire.skills.cache_hit", false); + return AcquisitionResult.Installed(bundle); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + logger.LogWarning(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + logger.LogDebug(ex, "Embedded Aspire skills bundle could not be staged for version {Version}.", version); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + finally + { + TryDeleteDirectory(tempDir); + } + } + + private static string? ValidateEmbeddedMetadata(EmbeddedAspireSkillsBundleMetadata metadata) + { + if (string.IsNullOrWhiteSpace(metadata.Version)) + { + return "Embedded Aspire skills metadata must specify a version."; + } + + if (!string.Equals(metadata.Repository, GitHubRepository, StringComparison.OrdinalIgnoreCase)) + { + return string.Format(CultureInfo.InvariantCulture, "Embedded Aspire skills metadata repository '{0}' does not match expected repository '{1}'.", metadata.Repository, GitHubRepository); + } + + if (string.IsNullOrWhiteSpace(metadata.Tag)) + { + return "Embedded Aspire skills metadata must specify a GitHub release tag."; + } + + if (string.IsNullOrWhiteSpace(metadata.AssetName)) + { + return "Embedded Aspire skills metadata must specify a release asset name."; + } + + if (string.IsNullOrWhiteSpace(metadata.Sha256)) + { + return "Embedded Aspire skills metadata must specify the release asset SHA-256 hash."; + } + + return null; + } + + private static void ValidateArchiveSha256(string archivePath, string expectedSha256) + { + var expectedHash = AspireSkillsBundle.NormalizeSha256(expectedSha256); + string actualHash; + using (var stream = File.OpenRead(archivePath)) + { + actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.", + expectedHash, + actualHash)); + } + } + private async Task TryGetGitHubReleaseAsync(HttpClient httpClient, string version, CancellationToken cancellationToken) { foreach (var tag in GetGitHubTagCandidates(version)) diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz new file mode 100644 index 00000000000..e25af3228df Binary files /dev/null and b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz differ diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json new file mode 100644 index 00000000000..4b76e028392 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -0,0 +1,7 @@ +{ + "version": "0.0.1", + "repository": "microsoft/aspire-skills", + "tag": "v0.0.1", + "assetName": "aspire-skills-v0.0.1.tgz", + "sha256": "8f0aa535917bb6d2589acbf8f986c7b0d622ee7744e39c526bb7166c0664b53c" +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs new file mode 100644 index 00000000000..3b7d70cadd1 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Agents.AspireSkills; + +/// +/// Provides access to the Aspire skills bundle snapshot embedded in the CLI assembly. +/// +internal interface IEmbeddedAspireSkillsBundleProvider +{ + /// + /// Gets metadata for the embedded Aspire skills bundle snapshot. + /// + EmbeddedAspireSkillsBundleMetadata? Metadata { get; } + + /// + /// Opens the embedded Aspire skills bundle archive. + /// + Stream? OpenArchive(); +} + +internal sealed class EmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider +{ + private const string ArchiveResourceName = "aspire-skills.bundle.tgz"; + private const string MetadataResourceName = "aspire-skills.metadata.json"; + + private readonly ILogger _logger; + private readonly Lazy _metadata; + + public EmbeddedAspireSkillsBundleProvider(ILogger logger) + { + _logger = logger; + _metadata = new Lazy(LoadMetadata); + } + + public EmbeddedAspireSkillsBundleMetadata? Metadata => _metadata.Value; + + public Stream? OpenArchive() + { + var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(ArchiveResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills archive resource {ResourceName} was not found.", ArchiveResourceName); + } + + return stream; + } + + private EmbeddedAspireSkillsBundleMetadata? LoadMetadata() + { + using var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(MetadataResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was not found.", MetadataResourceName); + return null; + } + + try + { + var metadata = JsonSerializer.Deserialize( + stream, + AspireSkillsJsonSerializerContext.Default.EmbeddedAspireSkillsBundleMetadata); + + if (metadata is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was empty.", MetadataResourceName); + } + + return metadata; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Embedded Aspire skills metadata resource {ResourceName} could not be parsed.", MetadataResourceName); + return null; + } + } +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index a41777944c1..5e48ed3f573 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -56,6 +56,22 @@ internal sealed class SkillBundleFile public string? Sha256 { get; init; } } +/// +/// Describes the Aspire skills bundle archive embedded in the CLI. +/// +internal sealed class EmbeddedAspireSkillsBundleMetadata +{ + public string? Version { get; init; } + + public string? Repository { get; init; } + + public string? Tag { get; init; } + + public string? AssetName { get; init; } + + public string? Sha256 { get; init; } +} + /// /// Source-generation context for Aspire skills bundle JSON. /// @@ -65,6 +81,7 @@ internal sealed class SkillBundleFile PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(SkillBundleManifest))] +[JsonSerializable(typeof(EmbeddedAspireSkillsBundleMetadata))] internal sealed partial class AspireSkillsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 096c95408cf..afd4bd73f5e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -297,6 +297,12 @@ + + false + + + false + diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index bfb45024e53..5788f880841 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -311,6 +311,8 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo } } + var installedSkills = new List(); + foreach (var location in selectedLocations) { context.AddSkillBaseDirectory(location.RelativeSkillDirectory); @@ -328,27 +330,39 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo continue; } - hasErrors |= !await InstallSkillAsync( + var installResult = await InstallSkillAsync( workspaceRoot, location.RelativeSkillDirectory, skill, aspireSkillsBundle, isUserLevel: false, cancellationToken); + hasErrors |= !installResult.Succeeded; + if (installResult.UpdatedSkill is not null) + { + installedSkills.Add(installResult.UpdatedSkill); + } if (location.IncludeUserLevel) { - hasErrors |= !await InstallSkillAsync( + installResult = await InstallSkillAsync( ExecutionContext.HomeDirectory, location.RelativeSkillDirectory, skill, aspireSkillsBundle, isUserLevel: true, cancellationToken); + hasErrors |= !installResult.Succeeded; + if (installResult.UpdatedSkill is not null) + { + installedSkills.Add(installResult.UpdatedSkill); + } } } } + DisplayInstalledSkillsSummary(installedSkills); + // --- Phase 4: Handle Playwright CLI (installs binary + mirrors skill files to registered directories) --- var selectedSkillDirs = selectedLocations.Select(l => l.RelativeSkillDirectory).ToHashSet(StringComparer.OrdinalIgnoreCase); if (selectedSkills.Contains(SkillDefinition.PlaywrightCli) && selectedLocations.Count > 0) @@ -424,8 +438,8 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo /// /// Installs the files for a skill at the specified location, creating or updating them as needed. /// - /// true if successful, false if an error occurred. - private async Task InstallSkillAsync( + /// The install result, including the skill/location pair when files were updated. + private async Task InstallSkillAsync( DirectoryInfo rootDirectory, string relativeSkillDirectory, SkillDefinition skill, @@ -465,23 +479,60 @@ private async Task InstallSkillAsync( if (!anyFileUpdated) { - return true; + return new(Succeeded: true, UpdatedSkill: null); } - var displayRelativeSkillPath = relativeSkillPath - .Replace(Path.DirectorySeparatorChar, '/') - .Replace(Path.AltDirectorySeparatorChar, '/'); - var displayPath = isUserLevel ? $"~/{displayRelativeSkillPath}" : displayRelativeSkillPath; - _interactionService.DisplayMessage(KnownEmojis.Robot, - string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkill, skill.Name, displayPath)); - return true; + var displayLocation = GetDisplaySkillDirectory(relativeSkillDirectory, isUserLevel); + return new(Succeeded: true, new InstalledSkillSummaryItem(skill.Name, displayLocation)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { _interactionService.DisplayError( string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_FailedToInstallSkill, skill.Name, fullSkillDirectoryPath, ex.Message)); - return false; + return new(Succeeded: false, UpdatedSkill: null); + } + } + + private void DisplayInstalledSkillsSummary(IReadOnlyList installedSkills) + { + if (installedSkills.Count == 0) + { + return; + } + + var skillNames = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.SkillName))); + var locations = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.DisplayLocation))); + var message = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, skillNames)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, locations)}"); + + _interactionService.DisplayMessage(KnownEmojis.Robot, message); + } + + private static IReadOnlyList GetUniqueValues(IEnumerable values) + { + var uniqueValues = new List(); + var seenValues = new HashSet(StringComparer.Ordinal); + + foreach (var value in values) + { + if (seenValues.Add(value)) + { + uniqueValues.Add(value); + } } + + return uniqueValues; + } + + private static string GetDisplaySkillDirectory(string relativeSkillDirectory, bool isUserLevel) + { + var displayRelativeSkillDirectory = relativeSkillDirectory + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + + return isUserLevel ? $"~/{displayRelativeSkillDirectory}" : displayRelativeSkillDirectory; } private static async Task> GetSkillFilesAsync(SkillDefinition skill, AspireSkillsBundle? aspireSkillsBundle, CancellationToken cancellationToken) @@ -509,6 +560,10 @@ private enum AgentInitErrorMode Strict, BestEffort } + + private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation); + + private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill); } internal readonly record struct AgentInitExecutionResult( diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index dc8df361491..8d6cc53c443 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -464,6 +464,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index f1e80b58211..6133c22ac5e 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -205,11 +205,29 @@ internal static string InitCommand_PlaywrightCliSkipped { } /// - /// Looks up a localized string similar to Installed {0} skill ({1}).. + /// Looks up a localized string similar to Installed Aspire agent skills:. /// - internal static string InitCommand_InstalledSkill { + internal static string InitCommand_InstalledSkillsSummary { get { - return ResourceManager.GetString("InitCommand_InstalledSkill", resourceCulture); + return ResourceManager.GetString("InitCommand_InstalledSkillsSummary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Skills: {0}. + /// + internal static string InitCommand_InstalledSkillsSummarySkills { + get { + return ResourceManager.GetString("InitCommand_InstalledSkillsSummarySkills", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Locations: {0}. + /// + internal static string InitCommand_InstalledSkillsSummaryLocations { + get { + return ResourceManager.GetString("InitCommand_InstalledSkillsSummaryLocations", resourceCulture); } } @@ -358,7 +376,7 @@ internal static string AspireSkillsInstaller_InstallingStatus { } /// - /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available.. + /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available.. /// internal static string AspireSkillsInstaller_GitHubUnavailable { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 275a332b141..2364ff91d8d 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -108,8 +108,14 @@ Playwright CLI requires npm, which was not found on PATH. Skipping installation. - - Installed {0} skill ({1}). + + Installed Aspire agent skills: + + + Skills: {0} + + + Locations: {0} Failed to install {0} skill at {1}: {2} @@ -160,7 +166,7 @@ Installing Aspire skills... - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. The Aspire skills bundle is invalid: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 4c3fa5cbf55..7139b9c497a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 9083254de4e..a09a22f0b5d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index d3657b160c0..416a73644e6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 297ceb6f134..38117b59c88 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 0735f52225e..670db6c9e77 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 922fd845ae4..297b0f9eb54 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 87a7da1cb2e..05a8359420d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 09c9a59599b..d87eccc448a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index cddc1e8fedd..66b4e8820f1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index f42eb379f17..76dc2665fa2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 8559bf134d6..65b85538393 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 2e2531cc0ad..f4c08201732 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 177fbc55ac9..62de972aa38 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index f17750521e0..3b418f7cc99 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -31,12 +31,14 @@ public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); await CreateCachedBundleAsync(cachedBundleDirectory); - var installer = CreateInstaller(executionContext); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -89,6 +91,47 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsF } } + [Fact] + public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() + { + var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); + + var metadata = Assert.IsType(provider.Metadata); + using var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive()); + + Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); + Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); + Assert.Equal(metadata.Sha256, ComputeSha256(archiveStream)); + } + + private static string ComputeSha256(Stream stream) + { + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + [Fact] public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() { @@ -115,7 +158,12 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() }); var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); @@ -127,6 +175,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath); Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType); Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); Assert.NotNull(releaseRequestUri); Assert.NotNull(assetRequestUri); Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath); @@ -139,7 +188,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() } [Fact] - public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() + public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBundle() { var rootDirectory = CreateTempDirectory(); @@ -163,13 +212,85 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() { Result = new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.WorkflowMismatch } }; - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(attestationVerifier.VerifyCalled); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenVersionOverrideDoesNotMatchEmbeddedBundle_DoesNotUseEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.VersionOverrideKey] = "9.9.9" + }) + .Build(); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailure() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + embeddedBundleProvider.Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + }; + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Provenance", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SHA-256", result.Message, StringComparison.Ordinal); + Assert.Contains("0000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -180,14 +301,17 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() private static AspireSkillsInstaller CreateInstaller( CliExecutionContext executionContext, HttpMessageHandler? httpMessageHandler = null, - TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null) + TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, + IConfiguration? configuration = null, + IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), new MockHttpClientFactory(httpMessageHandler ?? new MockHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))), + embeddedBundleProvider ?? new TestEmbeddedAspireSkillsBundleProvider(), new TestInteractionService(), executionContext, - new ConfigurationBuilder().Build(), + configuration ?? new ConfigurationBuilder().Build(), TestTelemetryHelper.CreateInitializedTelemetry(), NullLogger.Instance); } @@ -273,6 +397,28 @@ private static string ComputeSha256(string path) return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } + private static string ComputeSha256(byte[] bytes) + { + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } + + private static async Task CreateEmbeddedBundleProviderAsync() + { + var archiveBytes = await CreateBundleArchiveBytesAsync(); + return new TestEmbeddedAspireSkillsBundleProvider + { + Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = ComputeSha256(archiveBytes) + }, + ArchiveBytes = archiveBytes + }; + } + private static HttpResponseMessage CreateJsonResponse(string json) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -341,4 +487,18 @@ public Task VerifyAsync( } } + private sealed class TestEmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider + { + public EmbeddedAspireSkillsBundleMetadata? Metadata { get; set; } + + public byte[]? ArchiveBytes { get; init; } + + public bool OpenArchiveCalled { get; private set; } + + public Stream? OpenArchive() + { + OpenArchiveCalled = true; + return ArchiveBytes is null ? null : new MemoryStream(ArchiveBytes, writable: false); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 6c7d792f38d..ace28bcd5c4 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -17,7 +17,7 @@ namespace Aspire.Cli.Tests.Commands; public class AgentInitCommandTests(ITestOutputHelper outputHelper) { [Fact] - public async Task AgentInitCommand_UsesNormalizedDisplayPath_WhenInstallingUserLevelSkill() + public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallingUserLevelSkill() { using var workspace = TemporaryWorkspace.Create(outputHelper); var homeDirectory = workspace.CreateDirectory("fake-home"); @@ -44,13 +44,60 @@ public async Task AgentInitCommand_UsesNormalizedDisplayPath_WhenInstallingUserL var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); + var expectedSummary = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, SkillDefinition.Aspire.Name)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); + Assert.Contains( interactionService.DisplayedMessages, - displayedMessage => displayedMessage.Message == string.Format( + displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot) && displayedMessage.Message == expectedSummary); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + displayedMessage => displayedMessage.Message.Contains("Installed aspire skill", StringComparison.Ordinal)); + } + + [Fact] + public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var homeDirectory = workspace.CreateDirectory("fake-home"); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => choices.Cast() + .Where(choice => choice switch + { + SkillLocation location => location == SkillLocation.Standard, + SkillDefinition skill => skill.IsDefault, + _ => false + }) + .ToList(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory); + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + + var expectedSummary = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format( CultureInfo.CurrentCulture, - AgentCommandStrings.InitCommand_InstalledSkill, - SkillDefinition.Aspire.Name, - "~/.agents/skills/aspire")); + AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, + string.Join(", ", SkillDefinition.All.Where(static skill => skill.IsDefault).Select(static skill => skill.Name)))}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); + var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); + Assert.Equal(expectedSummary, message.Message); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + displayedMessage => displayedMessage.Message.Contains("Installed aspire skill", StringComparison.Ordinal)); } [Fact] diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 7ac8fdec09f..17ef7854130 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -812,6 +814,8 @@ public async Task PrepareStep_ResolvesContainerImageReferenceViaIValueProvider() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.AddDockerComposeEnvironment("docker-compose");