Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions dotnet/src/Devolutions.Pinget.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -765,9 +765,10 @@
ctx.ParseResult.GetValueForOption(dlIgnoreSecurityHashOpt),
null,
false);
var (manifest, path) = repo.DownloadInstaller(request, dir);
Console.WriteLine($"Downloaded {manifest.Name} v{manifest.Version}");
Console.WriteLine($" Path: {path}");
var result = repo.DownloadInstaller(request, dir);
Console.WriteLine($"Downloaded {result.Manifest.Name} v{result.Manifest.Version}");
Console.WriteLine($" Installer: {result.InstallerPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
});

// ── Pin commands ──
Expand Down
59 changes: 54 additions & 5 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,9 +1137,10 @@ public void DownloadInstaller_IgnoresHashMismatchWhenRequested()
IgnoreSecurityHash = true,
};

var (_, installerPath) = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads"));
Assert.True(File.Exists(installerPath));
Assert.Equal(payload, File.ReadAllBytes(installerPath));
var downloadResult = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads"));
Assert.True(File.Exists(downloadResult.InstallerPath));
Assert.Equal(payload, File.ReadAllBytes(downloadResult.InstallerPath));
Assert.True(File.Exists(downloadResult.ManifestPath));
}
finally
{
Expand Down Expand Up @@ -1179,8 +1180,8 @@ public void DownloadInstaller_SendsConfiguredRequestHeaders()
ManifestPath = manifestPath,
};

var (_, installerPath) = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads"));
Assert.True(File.Exists(installerPath));
var downloadResult = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads"));
Assert.True(File.Exists(downloadResult.InstallerPath));
Assert.Equal("Bearer test-token", authorizationHeader);
}
finally
Expand All @@ -1189,6 +1190,54 @@ public void DownloadInstaller_SendsConfiguredRequestHeaders()
}
}

[Fact]
public void DownloadInstaller_WritesManifestYamlAlongsideInstaller()
{
var payload = "pinget-test-payload"u8.ToArray();
using var server = new TestHttpServer(payload);
var appRoot = TestPaths.CreateTempAppRoot();
try
{
var manifestPath = TestPaths.WriteManifest(appRoot, $$"""
PackageIdentifier: Vendor.App
PackageVersion: 3.4.5
PackageName: Vendor App
Publisher: Vendor
ManifestType: merged
ManifestVersion: 1.10.0
Installers:
- Architecture: x64
InstallerType: exe
Scope: user
InstallerLocale: en-US
InstallerUrl: {{server.Url}}
""");

using var repo = Repository.Open(new RepositoryOptions { AppRoot = appRoot });
var request = new InstallRequest
{
Query = new PackageQuery(),
ManifestPath = manifestPath,
IgnoreSecurityHash = true,
};

var result = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads"));
Assert.True(File.Exists(result.ManifestPath));

// Filename follows winget's `{Name}_{Version}_{Scope}_{Arch}_{Type}_{Locale}.yaml`.
Assert.EndsWith("Vendor App_3.4.5_User_X64_exe_en-US.yaml", result.ManifestPath);

var yaml = File.ReadAllText(result.ManifestPath);
Assert.Contains("PackageIdentifier: Vendor.App", yaml);
Assert.Contains("PackageVersion: 3.4.5", yaml);
Assert.Contains("ManifestType: merged", yaml);
}
finally
{
TestPaths.DeleteAppRoot(appRoot);
}
}

[Fact]
public void BuildArguments_UsesManifestSwitchesByMode()
{
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,18 @@ public record InstallResult
public List<string> Warnings { get; init; } = [];
}

/// <summary>
/// Result of a <c>download</c> operation. Mirrors <c>winget download</c> by
/// returning both the installer file written to disk and a flattened YAML
/// manifest emitted next to it.
/// </summary>
public record DownloadResult
{
public required Manifest Manifest { get; init; }
public required string InstallerPath { get; init; }
public required string ManifestPath { get; init; }
}

// Internal type for installed package tracking
internal record InstalledPackage
{
Expand Down
192 changes: 164 additions & 28 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,12 +591,34 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi

// ── Install / Uninstall ──

public (Manifest Manifest, string InstallerPath) DownloadInstaller(PackageQuery query, string downloadDir)
public DownloadResult DownloadInstaller(PackageQuery query, string downloadDir)
=> DownloadInstaller(new InstallRequest { Query = query }, downloadDir);

public (Manifest Manifest, string InstallerPath) DownloadInstaller(InstallRequest request, string downloadDir)
public DownloadResult DownloadInstaller(InstallRequest request, string downloadDir)
{
var manifest = ResolveManifestForInstall(request);
var fetched = FetchInstaller(request, downloadDir);
var manifestYaml = BuildDownloadManifestYaml(fetched.Manifest, fetched.ManifestDocuments, fetched.Installer);
var manifestFileName = DeriveDownloadManifestFilename(fetched.Manifest, fetched.Installer);
var manifestPath = Path.Combine(downloadDir, manifestFileName);
File.WriteAllText(manifestPath, manifestYaml);

return new DownloadResult
{
Manifest = fetched.Manifest,
InstallerPath = fetched.InstallerPath,
ManifestPath = manifestPath,
};
}

/// <summary>
/// Fetches the installer file without emitting the YAML manifest. Used by
/// the install flow, which downloads into a temp dir and only needs the
/// installer path — emitting a manifest there would litter the temp dir
/// and turn a benign YAML-write failure into an installation failure.
/// </summary>
private FetchedInstaller FetchInstaller(InstallRequest request, string downloadDir)
{
var (manifest, manifestDocuments) = ResolveManifestAndDocumentsForInstall(request);
var installer = SelectInstaller(manifest.Installers, request.Query)
?? throw new InvalidOperationException("No applicable installer found for the current system");
var url = installer.Url ?? throw new InvalidOperationException("Installer has no URL");
Expand All @@ -611,7 +633,6 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi
var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
File.WriteAllBytes(dest, bytes);

// Verify hash
if (installer.Sha256 is not null)
{
var actual = Sha256Hex(bytes);
Expand All @@ -623,9 +644,11 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi
}
}

return (manifest, dest);
return new FetchedInstaller(manifest, manifestDocuments, installer, dest);
}

private sealed record FetchedInstaller(Manifest Manifest, object ManifestDocuments, Installer Installer, string InstallerPath);

public InstallResult Install(PackageQuery query, bool silent)
{
return Install(new InstallRequest
Expand Down Expand Up @@ -697,7 +720,7 @@ public InstallResult Install(InstallRequest request)
}

var tempDir = Path.Combine(Path.GetTempPath(), "pinget-install");
var (_, installerPath) = DownloadInstaller(request, tempDir);
var installerPath = FetchInstaller(request, tempDir).InstallerPath;

var installerType = (selectedInstaller.InstallerType ?? "exe").ToLowerInvariant();
var exitCode = InstallerDispatch.Execute(installerPath, installerType, request, manifest, selectedInstaller);
Expand Down Expand Up @@ -787,13 +810,16 @@ public InstallResult Uninstall(UninstallRequest request)
}

private Manifest ResolveManifestForInstall(InstallRequest request)
=> ResolveManifestAndDocumentsForInstall(request).Manifest;

private (Manifest Manifest, object Documents) ResolveManifestAndDocumentsForInstall(InstallRequest request)
{
if (!string.IsNullOrWhiteSpace(request.ManifestPath))
return LoadManifestFromPath(request.ManifestPath!);
return LoadManifestFromPathWithDocuments(request.ManifestPath!);

var (located, _, _) = FindSingleMatch(request.Query);
var (manifest, _, _) = ManifestForMatch(located, request.Query);
return manifest;
var (manifest, documents, _) = ManifestForMatch(located, request.Query);
return (manifest, documents);
}

private ListMatch? FindInstalledPackageForInstall(InstallRequest request, Manifest manifest)
Expand Down Expand Up @@ -1008,9 +1034,119 @@ private List<ListMatch> ResolveUninstallMatches(UninstallRequest request)
}

private static Manifest LoadManifestFromPath(string manifestPath)
=> LoadManifestFromPathWithDocuments(manifestPath).Manifest;

private static (Manifest Manifest, object Documents) LoadManifestFromPathWithDocuments(string manifestPath)
{
var resolved = ResolveManifestPath(manifestPath);
return ParseYamlManifest(File.ReadAllBytes(resolved));
var bytes = File.ReadAllBytes(resolved);
return (ParseYamlManifest(bytes), ParseYamlManifestDocuments(bytes));
}

/// <summary>
/// Builds the YAML manifest emitted next to the installer by
/// <c>winget download</c>. The structured document is the merged singleton
/// the source provided; we narrow the <c>Installers</c> array to the
/// resolved installer so the file describes exactly the binary written.
/// Identity fields are overridden from the typed manifest because
/// <see cref="ManifestForMatch"/> corrects them after the initial YAML
/// parse.
/// </summary>
internal static string BuildDownloadManifestYaml(Manifest manifest, object documents, Installer installer)
{
var document = StructuredOutput.CollapseManifestDocuments(documents);

document["PackageIdentifier"] = manifest.Id;
document["PackageVersion"] = manifest.Version;
document["PackageName"] = manifest.Name;
if (!string.IsNullOrEmpty(manifest.Channel))
document["Channel"] = manifest.Channel;
else
document.Remove("Channel");

var singleInstaller = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(installer.Architecture)) singleInstaller["Architecture"] = installer.Architecture;
if (!string.IsNullOrEmpty(installer.InstallerType)) singleInstaller["InstallerType"] = installer.InstallerType;
if (!string.IsNullOrEmpty(installer.Url)) singleInstaller["InstallerUrl"] = installer.Url;
if (!string.IsNullOrEmpty(installer.Sha256)) singleInstaller["InstallerSha256"] = installer.Sha256;
if (!string.IsNullOrEmpty(installer.Scope)) singleInstaller["Scope"] = installer.Scope;
if (!string.IsNullOrEmpty(installer.Locale)) singleInstaller["InstallerLocale"] = installer.Locale;
if (!string.IsNullOrEmpty(installer.ProductCode)) singleInstaller["ProductCode"] = installer.ProductCode;
if (!string.IsNullOrEmpty(installer.ReleaseDate)) singleInstaller["ReleaseDate"] = installer.ReleaseDate;

document["Installers"] = new List<object?> { singleInstaller };
document["ManifestType"] = "merged";

return YamlEmitter.EmitDocument(document);
}

/// <summary>
/// Computes the file name <c>winget download</c> uses for the YAML
/// manifest emitted next to the installer:
/// <c>{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml</c>.
/// Mirrors <c>GetInstallerDownloadOnlyFileName</c> in winget-cli's
/// DownloadFlow.
/// </summary>
internal static string DeriveDownloadManifestFilename(Manifest manifest, Installer installer)
=> DeriveDownloadFilenameStem(manifest, installer) + ".yaml";

internal static string DeriveDownloadFilenameStem(Manifest manifest, Installer installer)
{
var stem = new System.Text.StringBuilder();
stem.Append(SanitizeFilenameComponent(manifest.Name));
if (!string.IsNullOrEmpty(manifest.Version) &&
!string.Equals(manifest.Version, "Unknown", StringComparison.OrdinalIgnoreCase))
{
stem.Append('_');
stem.Append(SanitizeFilenameComponent(manifest.Version));
}
if (!string.IsNullOrEmpty(installer.Scope))
{
stem.Append('_');
stem.Append(SanitizeFilenameComponent(CapitalizeFirst(installer.Scope)));
}

var architecture = string.IsNullOrEmpty(installer.Architecture) ? "neutral" : installer.Architecture;
stem.Append('_');
stem.Append(SanitizeFilenameComponent(FormatArchitecture(architecture)));

if (!string.IsNullOrEmpty(installer.InstallerType))
{
stem.Append('_');
stem.Append(SanitizeFilenameComponent(installer.InstallerType.ToLowerInvariant()));
}
if (!string.IsNullOrEmpty(installer.Locale))
{
stem.Append('_');
stem.Append(SanitizeFilenameComponent(installer.Locale));
}

return stem.ToString();
}

private static string CapitalizeFirst(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return char.ToUpperInvariant(value[0]) + value[1..].ToLowerInvariant();
}

private static string FormatArchitecture(string value) => value.ToLowerInvariant() switch
{
"x64" => "X64",
"x86" => "X86",
"arm" => "Arm",
"arm64" => "Arm64",
"neutral" => "Neutral",
_ => CapitalizeFirst(value),
};

private static string SanitizeFilenameComponent(string value)
{
var invalid = new HashSet<char>(Path.GetInvalidFileNameChars());
var builder = new System.Text.StringBuilder(value.Length);
foreach (var ch in value)
builder.Append(invalid.Contains(ch) || char.IsControl(ch) ? '_' : ch);
return builder.ToString();
}

private static string ResolveManifestPath(string manifestPath)
Expand Down Expand Up @@ -1678,29 +1814,29 @@ private static bool ReadBool(IDictionary<object, object> dict, string key)
parser.MoveNext();
return ScalarToValue(scalar);
case YamlDotNet.Core.Events.MappingStart:
{
parser.MoveNext();
var dict = new Dictionary<object, object?>();
while (parser.Current is not YamlDotNet.Core.Events.MappingEnd)
{
var key = ReadYamlNode(parser);
var value = ReadYamlNode(parser);
if (key is not null) dict[key] = value;
parser.MoveNext();
var dict = new Dictionary<object, object?>();
while (parser.Current is not YamlDotNet.Core.Events.MappingEnd)
{
var key = ReadYamlNode(parser);
var value = ReadYamlNode(parser);
if (key is not null) dict[key] = value;
}
parser.MoveNext();
return dict;
}
parser.MoveNext();
return dict;
}
case YamlDotNet.Core.Events.SequenceStart:
{
parser.MoveNext();
var list = new List<object?>();
while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd)
{
list.Add(ReadYamlNode(parser));
parser.MoveNext();
var list = new List<object?>();
while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd)
{
list.Add(ReadYamlNode(parser));
}
parser.MoveNext();
return list;
}
parser.MoveNext();
return list;
}
default:
parser.MoveNext();
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public sealed class PSDownloadResult

public required string DownloadedInstallerPath { get; init; }

public string? DownloadedManifestPath { get; init; }

public bool Succeeded() => string.Equals(Status, "Ok", StringComparison.OrdinalIgnoreCase);

public string ErrorMessage() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ public CommandResult<PSDownloadResult> DownloadPackage(
var response = RequiredObject(_packageManager.DownloadInstallerJson(ToJsonString(request), outputDirectory));
var manifest = RequiredObject(response, "manifest");
var installerPath = RequiredString(response, "installer_path");
var manifestPath = OptionalString(response, "manifest_path");
return new CommandResult<PSDownloadResult>(
new PSDownloadResult
{
Expand All @@ -194,6 +195,7 @@ public CommandResult<PSDownloadResult> DownloadPackage(
Version = RequiredString(manifest, "version"),
DownloadDirectory = outputDirectory,
DownloadedInstallerPath = installerPath,
DownloadedManifestPath = manifestPath,
},
warnings);
}
Expand Down
Loading
Loading