| File: Agents\Hooks\TelemetryHookInstaller.cs | Web Access |
| Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire) |
// 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; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Agents.Hooks; /// <summary> /// Default <see cref="ITelemetryHookInstaller"/> that materializes the hook scripts embedded in the /// CLI assembly to <c>~/.aspire/hooks</c>. /// </summary> internal sealed class TelemetryHookInstaller : ITelemetryHookInstaller { // LogicalNames declared in Aspire.Cli.csproj. private const string ShellResourceName = "track-telemetry.sh"; private const string PowerShellResourceName = "track-telemetry.ps1"; private const string HooksDirectoryName = "hooks"; // UTF-8 without a BOM: the shell script must not begin with a BOM or the shebang is ignored, // and the PowerShell script is ASCII so a BOM is unnecessary. private static readonly UTF8Encoding s_utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly CliExecutionContext _executionContext; private readonly ILogger<TelemetryHookInstaller> _logger; public TelemetryHookInstaller(CliExecutionContext executionContext, ILogger<TelemetryHookInstaller> logger) { ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(logger); _executionContext = executionContext; _logger = logger; } /// <inheritdoc /> public async Task<TelemetryHookScripts> EnsureInstalledAsync(CancellationToken cancellationToken) { var hooksDirectory = Path.Combine(_executionContext.AspireHomeDirectory.FullName, HooksDirectoryName); Directory.CreateDirectory(hooksDirectory); var shellPath = Path.Combine(hooksDirectory, ShellResourceName); var powerShellPath = Path.Combine(hooksDirectory, PowerShellResourceName); // The shell script must use LF endings even on Windows because the agent may run it under a // POSIX shell (WSL/Git bash); CRLF would surface as `$'\r': command not found` errors. var shellContent = NormalizeToLf(ReadEmbeddedText(ShellResourceName)); var powerShellContent = ReadEmbeddedText(PowerShellResourceName); await WriteFileIfChangedAsync(shellPath, shellContent, cancellationToken); await WriteFileIfChangedAsync(powerShellPath, powerShellContent, cancellationToken); // Ensure the shell script is executable so a `bash <path>` (or direct exec) hook entry works. // Spawning `chmod` would add PATH/shell failure modes, so use the platform API directly. TrySetExecutable(shellPath); return new TelemetryHookScripts(shellPath, powerShellPath); } private static string NormalizeToLf(string content) => content.Replace("\r\n", "\n").Replace("\r", "\n"); private static string ReadEmbeddedText(string resourceName) { using var stream = typeof(TelemetryHookInstaller).Assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Embedded telemetry hook script '{resourceName}' was not found in the CLI assembly."); using var reader = new StreamReader(stream, s_utf8NoBom); return reader.ReadToEnd(); } private async Task WriteFileIfChangedAsync(string path, string content, CancellationToken cancellationToken) { // Skip the write when the content already matches so a running hook isn't disturbed and the // file mtime stays stable across repeated `agent init` runs. if (File.Exists(path)) { try { var existing = await File.ReadAllTextAsync(path, s_utf8NoBom, cancellationToken); if (string.Equals(existing, content, StringComparison.Ordinal)) { return; } } catch (IOException ex) { _logger.LogDebug(ex, "Could not read existing telemetry hook script at {Path}; it will be rewritten.", path); } } // Write to a sibling temp file then atomically move into place so a concurrently executing // hook never observes a partially written script. var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); await File.WriteAllTextAsync(tempPath, content, s_utf8NoBom, cancellationToken); File.Move(tempPath, path, overwrite: true); } private void TrySetExecutable(string path) { if (OperatingSystem.IsWindows()) { return; } try { var mode = File.GetUnixFileMode(path); File.SetUnixFileMode(path, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) { // Non-fatal: a `bash <path>` hook entry still runs the script without the executable bit. _logger.LogDebug(ex, "Could not set executable bit on telemetry hook script at {Path}.", path); } } }