diff --git a/.github/workflows/cli-packages.yml b/.github/workflows/cli-packages.yml
new file mode 100644
index 0000000..2f50202
--- /dev/null
+++ b/.github/workflows/cli-packages.yml
@@ -0,0 +1,296 @@
+name: CLI NuGet Packages
+
+on:
+ push:
+ tags-ignore:
+ - 'v*'
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ dry-run:
+ description: Build and package only; skip publish/release actions.
+ required: false
+ default: true
+ type: boolean
+
+permissions:
+ contents: read
+
+env:
+ DOTNET_NOLOGO: true
+ CARGO_TERM_COLOR: always
+
+jobs:
+ rust-cli:
+ name: Rust CLI (${{ matrix.rid }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x64
+ os: ubuntu-latest
+ rust-target: x86_64-unknown-linux-gnu
+ - rid: linux-arm64
+ os: ubuntu-latest
+ rust-target: aarch64-unknown-linux-gnu
+ - rid: win-x64
+ os: windows-latest
+ rust-target: x86_64-pc-windows-msvc
+ - rid: win-arm64
+ os: windows-latest
+ rust-target: aarch64-pc-windows-msvc
+ - rid: osx-x64
+ os: macos-14
+ rust-target: x86_64-apple-darwin
+ - rid: osx-arm64
+ os: macos-14
+ rust-target: aarch64-apple-darwin
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install Rust toolchain
+ shell: pwsh
+ run: |
+ rustup toolchain install stable --profile minimal --target "${{ matrix.rust-target }}"
+ rustup default stable
+
+ - name: Install Linux ARM64 linker
+ if: matrix.rid == 'linux-arm64'
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Configure static MSVC runtime
+ if: contains(matrix.rust-target, 'windows-msvc')
+ shell: pwsh
+ run: |
+ "RUSTFLAGS=-C target-feature=+crt-static" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Build and stage Rust CLI
+ shell: pwsh
+ env:
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
+ run: |
+ pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') `
+ -Package Rust `
+ -RustRuntimeIdentifiers '${{ matrix.rid }}' `
+ -NoPack `
+ -Clean
+
+ - name: Upload Rust CLI artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: rust-cli-${{ matrix.rid }}
+ path: artifacts/cli/rust/${{ matrix.rid }}/*
+ if-no-files-found: error
+
+ dotnet-cli:
+ name: .NET NativeAOT CLI (${{ matrix.rid }})
+ runs-on: windows-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ rid:
+ - win-x64
+ - win-arm64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Build and stage .NET NativeAOT CLI
+ shell: pwsh
+ run: |
+ pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') `
+ -Package DotNet `
+ -DotNetRuntimeIdentifiers '${{ matrix.rid }}' `
+ -NoPack `
+ -Clean
+
+ - name: Upload .NET CLI artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: dotnet-cli-${{ matrix.rid }}
+ path: artifacts/cli/dotnet-nativeaot/${{ matrix.rid }}/*
+ if-no-files-found: error
+
+ package:
+ name: Pack and smoke test
+ runs-on: ubuntu-latest
+ needs:
+ - rust-cli
+ - dotnet-cli
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Download CLI artifacts
+ uses: actions/download-artifact@v8
+ with:
+ path: artifacts/download
+
+ - name: Arrange staged artifacts
+ shell: pwsh
+ run: |
+ $Mappings = @{
+ 'rust-cli-linux-x64' = 'artifacts/cli/rust/linux-x64'
+ 'rust-cli-linux-arm64' = 'artifacts/cli/rust/linux-arm64'
+ 'rust-cli-win-x64' = 'artifacts/cli/rust/win-x64'
+ 'rust-cli-win-arm64' = 'artifacts/cli/rust/win-arm64'
+ 'rust-cli-osx-x64' = 'artifacts/cli/rust/osx-x64'
+ 'rust-cli-osx-arm64' = 'artifacts/cli/rust/osx-arm64'
+ 'dotnet-cli-win-x64' = 'artifacts/cli/dotnet-nativeaot/win-x64'
+ 'dotnet-cli-win-arm64' = 'artifacts/cli/dotnet-nativeaot/win-arm64'
+ }
+
+ foreach ($mapping in $Mappings.GetEnumerator()) {
+ $source = Join-Path 'artifacts/download' $mapping.Key
+ if (-not (Test-Path -Path $source)) {
+ throw "Downloaded artifact directory not found: $source"
+ }
+
+ New-Item -Path $mapping.Value -ItemType Directory -Force | Out-Null
+ Copy-Item -Path (Join-Path $source '*') -Destination $mapping.Value -Recurse -Force
+ }
+
+ - name: Pack CLI NuGet packages
+ shell: pwsh
+ run: |
+ pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackages.ps1') -NoBuild -Clean:$false
+
+ - name: Smoke test PackageReference copy targets
+ shell: pwsh
+ run: |
+ $packageSource = (Resolve-Path 'artifacts/cli-nuget').Path
+ $smokeRoot = Join-Path $env:RUNNER_TEMP 'pinget-cli-package-smoke'
+ $env:NUGET_PACKAGES = Join-Path $env:RUNNER_TEMP 'pinget-cli-package-smoke-nuget-cache'
+ New-Item -Path $smokeRoot -ItemType Directory -Force | Out-Null
+ New-Item -Path $env:NUGET_PACKAGES -ItemType Directory -Force | Out-Null
+ $rustPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Cli.Rust.*.nupkg' | Select-Object -First 1
+ $dotNetPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Cli.DotNet.*.nupkg' | Select-Object -First 1
+ if (($null -eq $rustPackage) -or ($null -eq $dotNetPackage)) {
+ throw 'Smoke test packages were not found.'
+ }
+ $rustVersion = $rustPackage.BaseName.Substring('Devolutions.Pinget.Cli.Rust.'.Length)
+ $dotNetVersion = $dotNetPackage.BaseName.Substring('Devolutions.Pinget.Cli.DotNet.'.Length)
+
+ dotnet new console --framework net10.0 --output $smokeRoot
+ if ($LASTEXITCODE -ne 0) { throw 'Smoke project creation failed.' }
+
+ $projectPath = Join-Path $smokeRoot 'pinget-cli-package-smoke.csproj'
+ $project = Get-Content -Path $projectPath -Raw
+ $project = $project -replace '', @"
+
+ pinget-rust.exe
+ pinget-dotnet.exe
+
+
+
+
+
+
+ "@
+ Set-Content -Path $projectPath -Value $project -Encoding utf8
+
+ $nugetConfig = Join-Path $smokeRoot 'NuGet.Config'
+ @"
+
+
+
+
+
+
+
+
+ "@ | Set-Content -Path $nugetConfig -Encoding utf8
+
+ dotnet build $projectPath --configfile $nugetConfig
+ if ($LASTEXITCODE -ne 0) { throw 'Smoke project build failed.' }
+
+ $expectedFiles = @(
+ 'bin/Debug/net10.0/runtimes/win-x64/native/pinget.exe',
+ 'bin/Debug/net10.0/runtimes/win-x64/native/pinget-rust.exe',
+ 'bin/Debug/net10.0/runtimes/win-x64/native/pinget-dotnet.exe',
+ 'bin/Debug/net10.0/runtimes/win-x64/native/e_sqlite3.dll',
+ 'bin/Debug/net10.0/runtimes/linux-x64/native/pinget'
+ )
+
+ foreach ($relativePath in $expectedFiles) {
+ $path = Join-Path $smokeRoot $relativePath
+ if (-not (Test-Path -Path $path -PathType Leaf)) {
+ throw "Expected smoke output file was not found: $path"
+ }
+ }
+
+ - name: Smoke test dotnet tool package
+ shell: pwsh
+ run: |
+ $packageSource = (Resolve-Path 'artifacts/cli-nuget').Path
+ $toolPackage = Get-ChildItem -Path $packageSource -Filter 'Devolutions.Pinget.Tool.*.nupkg' | Select-Object -First 1
+ if ($null -eq $toolPackage) {
+ throw 'Devolutions.Pinget.Tool package was not found.'
+ }
+
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($toolPackage.FullName)
+ try {
+ $entries = @($archive.Entries | ForEach-Object { $_.FullName })
+ $nuspecEntry = $archive.Entries | Where-Object { $_.FullName -match '\.nuspec$' } | Select-Object -First 1
+ if ($null -eq $nuspecEntry) {
+ throw "Unable to find nuspec entry in $($toolPackage.Name)."
+ }
+
+ $reader = [System.IO.StreamReader]::new($nuspecEntry.Open())
+ try {
+ $nuspec = $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Dispose()
+ }
+
+ if ($nuspec -notmatch '\native` |
+| `Devolutions.Pinget.Cli.DotNet` | C# CLI | Trimmed NativeAOT executable assets named `pinget.exe`, plus required native sidecars, starting with Windows x64 and arm64 |
+
+These packages are intended for `PackageReference` consumption and copy the executable assets into the consuming project's output/publish output through MSBuild targets. They are not `dotnet tool` packages; use `Devolutions.Pinget.Tool` when you want to install the CLI with `dotnet tool install`.
+
+```powershell
+dotnet add package Devolutions.Pinget.Cli.Rust
+dotnet add package Devolutions.Pinget.Cli.DotNet
+```
+
+Both packages use `pinget[.exe]` inside the NuGet package. If a project intentionally references both implementations, set package-specific output names to add distinguishable copies in the build output:
+
+```xml
+
+ pinget.exe
+ pinget-managed.exe
+
+```
+
## Non-goals
Pinget intentionally excludes:
diff --git a/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj b/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj
index deb666b..e98a569 100644
--- a/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj
+++ b/dotnet/src/Devolutions.Pinget.Cli/Devolutions.Pinget.Cli.csproj
@@ -15,6 +15,33 @@
enable
pinget
Devolutions.Pinget.Cli
+ Pinget CLI
+ Pinget
+ Pinget CLI
+ Copyright 2021-2026 Devolutions Inc.
+ true
+ pinget
+ Devolutions.Pinget.Tool
+ pinget;winget;package-manager;cli;dotnet-tool
+ README.md
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs
index fa43cc4..4548497 100644
--- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs
+++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs
@@ -5,7 +5,7 @@
using Devolutions.Pinget.Core;
using YamlDotNet.Serialization;
-const string Version = "0.3.0";
+const string Version = "0.4.0";
const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made.";
if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase)))
diff --git a/dotnet/src/Devolutions.Pinget.Cli/README.md b/dotnet/src/Devolutions.Pinget.Cli/README.md
new file mode 100644
index 0000000..30ac0a5
--- /dev/null
+++ b/dotnet/src/Devolutions.Pinget.Cli/README.md
@@ -0,0 +1,26 @@
+# Devolutions.Pinget.Tool
+
+`Devolutions.Pinget.Tool` installs the C# Pinget CLI as a framework-dependent .NET tool.
+
+## Install globally
+
+```powershell
+dotnet tool install -g Devolutions.Pinget.Tool
+```
+
+## Install in a local tool manifest
+
+```powershell
+dotnet new tool-manifest
+dotnet tool install Devolutions.Pinget.Tool
+dotnet tool run pinget -- --help
+```
+
+## Run
+
+```powershell
+pinget --help
+pinget --info
+```
+
+This package uses the C# Pinget implementation and requires a compatible .NET runtime on the target machine. It is separate from the `Devolutions.Pinget.Cli.Rust` and `Devolutions.Pinget.Cli.DotNet` packages, which are build-time `PackageReference` packages that copy prebuilt executables into another project's output.
diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1
index e7d520d..5c32fbf 100644
--- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1
+++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1
@@ -1,6 +1,6 @@
@{
RootModule = 'Devolutions.Pinget.Client.psm1'
- ModuleVersion = '0.3.0'
+ ModuleVersion = '0.4.0'
CompatiblePSEditions = @('Desktop', 'Core')
GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd'
Author = 'Devolutions'
diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs
index b0feeae..84b1cab 100644
--- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs
+++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs
@@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine;
public static class PowerShellEngineVersion
{
- public const string Current = "0.3.0";
+ public const string Current = "0.4.0";
}
diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj
new file mode 100644
index 0000000..c45a78c
--- /dev/null
+++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj
@@ -0,0 +1,40 @@
+
+
+
+ 0.4.0
+ Devolutions Inc.
+ Devolutions
+ Devolutions.Pinget.Cli.DotNet
+ pinget;winget;package-manager;cli;dotnet;nativeaot
+ Prebuilt trimmed NativeAOT .NET Pinget CLI executable for use from .NET projects.
+ netstandard2.0
+ true
+ false
+ false
+ true
+ false
+ true
+ MIT
+ https://github.com/Devolutions/pinget
+ https://github.com/Devolutions/pinget.git
+ git
+ false
+
+
+
+
+ runtimes\win-x64\native\pinget.exe
+
+
+ runtimes\win-x64\native\e_sqlite3.dll
+
+
+ runtimes\win-arm64\native\pinget.exe
+
+
+ runtimes\win-arm64\native\e_sqlite3.dll
+
+
+
+
+
diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets
new file mode 100644
index 0000000..9572bbd
--- /dev/null
+++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.targets
@@ -0,0 +1,43 @@
+
+
+ pinget.exe
+
+
+
+
+ runtimes\win-x64\native\$(DevolutionsPingetDotNetCliWindowsOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+ runtimes\win-x64\native\e_sqlite3.dll
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\win-arm64\native\$(DevolutionsPingetDotNetCliWindowsOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+ runtimes\win-arm64\native\e_sqlite3.dll
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj
new file mode 100644
index 0000000..dd25847
--- /dev/null
+++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj
@@ -0,0 +1,46 @@
+
+
+
+ 0.4.0
+ Devolutions Inc.
+ Devolutions
+ Devolutions.Pinget.Cli.Rust
+ pinget;winget;package-manager;cli;rust;native
+ Prebuilt Rust Pinget CLI executable for use from .NET projects.
+ netstandard2.0
+ true
+ false
+ false
+ true
+ false
+ true
+ MIT
+ https://github.com/Devolutions/pinget
+ https://github.com/Devolutions/pinget.git
+ git
+ false
+
+
+
+
+ runtimes\win-x64\native\pinget.exe
+
+
+ runtimes\win-arm64\native\pinget.exe
+
+
+ runtimes\linux-x64\native
+
+
+ runtimes\linux-arm64\native
+
+
+ runtimes\osx-x64\native
+
+
+ runtimes\osx-arm64\native
+
+
+
+
+
diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets
new file mode 100644
index 0000000..53334e0
--- /dev/null
+++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.targets
@@ -0,0 +1,72 @@
+
+
+ pinget.exe
+ pinget
+
+
+
+
+ runtimes\win-x64\native\$(DevolutionsPingetRustCliWindowsOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\win-arm64\native\$(DevolutionsPingetRustCliWindowsOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\linux-x64\native\$(DevolutionsPingetRustCliUnixOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\linux-arm64\native\$(DevolutionsPingetRustCliUnixOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\osx-x64\native\$(DevolutionsPingetRustCliUnixOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
+
+
+ runtimes\osx-arm64\native\$(DevolutionsPingetRustCliUnixOutputName)
+ PreserveNewest
+ PreserveNewest
+ Included
+ false
+ false
+
+
+
diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml
index 362b032..e515ccc 100644
--- a/rust/crates/pinget-cli/Cargo.toml
+++ b/rust/crates/pinget-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "pinget-cli"
-version = "0.3.0"
+version = "0.4.0"
edition = "2024"
[lints]
@@ -13,7 +13,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
-pinget-core = { version = "0.3.1", path = "../pinget-core" }
+pinget-core = { version = "0.4.0", path = "../pinget-core" }
chrono = "0.4.44"
dirs = "6.0"
jsonschema = "0.30"
@@ -27,3 +27,6 @@ sha2 = "0.11.0"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
winreg = "0.55.0"
+
+[target.'cfg(windows)'.build-dependencies]
+winresource = "0.1.30"
diff --git a/rust/crates/pinget-cli/build.rs b/rust/crates/pinget-cli/build.rs
new file mode 100644
index 0000000..949f068
--- /dev/null
+++ b/rust/crates/pinget-cli/build.rs
@@ -0,0 +1,28 @@
+#[cfg(windows)]
+fn main() {
+ let version = env!("CARGO_PKG_VERSION");
+ let version_string = format!("{version}.0");
+ let version_info = version
+ .split('.')
+ .chain(std::iter::repeat("0"))
+ .take(4)
+ .map(|part| part.parse::().expect("package version segment must fit in u16"))
+ .fold(0u64, |acc, part| (acc << 16) | u64::from(part));
+
+ let mut resource = winresource::WindowsResource::new();
+ resource
+ .set("CompanyName", "Devolutions Inc.")
+ .set("FileDescription", "Pinget CLI")
+ .set("FileVersion", &version_string)
+ .set("InternalName", "pinget")
+ .set("LegalCopyright", "Copyright 2021-2026 Devolutions Inc.")
+ .set("OriginalFilename", "pinget.exe")
+ .set("ProductName", "Pinget")
+ .set("ProductVersion", &version_string)
+ .set_version_info(winresource::VersionInfo::FILEVERSION, version_info)
+ .set_version_info(winresource::VersionInfo::PRODUCTVERSION, version_info);
+ resource.compile().expect("failed to compile Windows version resource");
+}
+
+#[cfg(not(windows))]
+fn main() {}
diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml
index 120d6c4..e22a5b7 100644
--- a/rust/crates/pinget-com/Cargo.toml
+++ b/rust/crates/pinget-com/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "pinget-com"
-version = "0.3.0"
+version = "0.4.0"
edition = "2024"
description = "Windows-only native COM bridge for Pinget backed by pinget-core."
license = "MIT"
diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml
index 2acd108..7226887 100644
--- a/rust/crates/pinget-core/Cargo.toml
+++ b/rust/crates/pinget-core/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "pinget-core"
-version = "0.3.1"
+version = "0.4.0"
edition = "2024"
description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM."
license = "MIT"
diff --git a/scripts/Build-CliNativeNuGetPackages.ps1 b/scripts/Build-CliNativeNuGetPackages.ps1
new file mode 100644
index 0000000..52e88ac
--- /dev/null
+++ b/scripts/Build-CliNativeNuGetPackages.ps1
@@ -0,0 +1,548 @@
+param(
+ [ValidateSet('All', 'Rust', 'DotNet', 'Tool')]
+ [string]$Package = 'All',
+
+ [string]$Version,
+
+ [string]$Configuration = 'Release',
+
+ [string]$StagingRoot,
+
+ [string]$OutputRoot,
+
+ [string[]]$RustRuntimeIdentifiers = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'),
+
+ [string[]]$DotNetRuntimeIdentifiers = @('win-x64', 'win-arm64'),
+
+ [switch]$NoBuild,
+
+ [switch]$NoPack,
+
+ [switch]$Clean
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+if ([string]::IsNullOrWhiteSpace($StagingRoot)) {
+ $StagingRoot = Join-Path $repoRoot 'artifacts\cli'
+}
+elseif (-not [System.IO.Path]::IsPathRooted($StagingRoot)) {
+ $StagingRoot = Join-Path $repoRoot $StagingRoot
+}
+
+if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
+ $OutputRoot = Join-Path $repoRoot 'artifacts\cli-nuget'
+}
+elseif (-not [System.IO.Path]::IsPathRooted($OutputRoot)) {
+ $OutputRoot = Join-Path $repoRoot $OutputRoot
+}
+
+function Invoke-NativeCommand {
+ param(
+ [Parameter(Mandatory)]
+ [string]$FilePath,
+
+ [string[]]$ArgumentList
+ )
+
+ Write-Host ">> $FilePath $($ArgumentList -join ' ')"
+ & $FilePath @ArgumentList
+ if ($LASTEXITCODE -ne 0) {
+ throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')"
+ }
+}
+
+function Get-Win32VersionParts {
+ param([Parameter(Mandatory)][string]$Version)
+
+ $parts = @($Version.Split('+')[0].Split('-')[0].Split('.') | ForEach-Object {
+ if ($_ -notmatch '^\d+$') {
+ throw "Version segment is not numeric: $_"
+ }
+
+ $value = [int]$_
+ if (($value -lt 0) -or ($value -gt 65535)) {
+ throw "Version segment is outside the Win32 VERSIONINFO range: $_"
+ }
+
+ $value
+ })
+
+ while ($parts.Count -lt 4) {
+ $parts += 0
+ }
+
+ if ($parts.Count -gt 4) {
+ throw "Win32 VERSIONINFO supports at most four version segments: $Version"
+ }
+
+ $parts
+}
+
+function Resolve-WindowsResourceCompiler {
+ $rcCommand = Get-Command rc.exe -ErrorAction SilentlyContinue
+ if ($null -ne $rcCommand) {
+ return $rcCommand.Source
+ }
+
+ $windowsKitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
+ if (Test-Path -Path $windowsKitsRoot -PathType Container) {
+ $kitVersions = @(Get-ChildItem -Path $windowsKitsRoot -Directory | Sort-Object -Property Name -Descending)
+ foreach ($kitVersion in $kitVersions) {
+ foreach ($arch in @('x64', 'x86', 'arm64')) {
+ $candidate = Join-Path $kitVersion.FullName "$arch\rc.exe"
+ if (Test-Path -Path $candidate -PathType Leaf) {
+ return $candidate
+ }
+ }
+ }
+ }
+
+ throw 'Unable to locate rc.exe from the Windows SDK.'
+}
+
+function New-PingetWindowsVersionResource {
+ param(
+ [Parameter(Mandatory)][string]$OutputDirectory,
+ [Parameter(Mandatory)][string]$Version
+ )
+
+ if (-not $IsWindows) {
+ throw 'Windows version resources can only be generated on Windows.'
+ }
+
+ New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null
+
+ $versionParts = @(Get-Win32VersionParts -Version $Version)
+ $versionCsv = $versionParts -join ','
+ $versionString = $versionParts -join '.'
+ $resourceScript = Join-Path $OutputDirectory 'pinget-version.rc'
+ $resourceFile = Join-Path $OutputDirectory 'pinget-version.res'
+
+ @"
+1 VERSIONINFO
+ FILEVERSION $versionCsv
+ PRODUCTVERSION $versionCsv
+ FILEFLAGSMASK 0x3fL
+ FILEFLAGS 0x0L
+ FILEOS 0x40004L
+ FILETYPE 0x1L
+ FILESUBTYPE 0x0L
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904b0"
+ BEGIN
+ VALUE "CompanyName", "Devolutions Inc."
+ VALUE "FileDescription", "Pinget CLI"
+ VALUE "FileVersion", "$versionString"
+ VALUE "InternalName", "pinget"
+ VALUE "LegalCopyright", "Copyright 2021-2026 Devolutions Inc."
+ VALUE "OriginalFilename", "pinget.exe"
+ VALUE "ProductName", "Pinget"
+ VALUE "ProductVersion", "$versionString"
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x0409, 1200
+ END
+END
+"@ | Set-Content -Path $resourceScript -Encoding ascii
+
+ $rc = Resolve-WindowsResourceCompiler
+ Invoke-NativeCommand -FilePath $rc -ArgumentList @('/nologo', "/fo$resourceFile", $resourceScript)
+
+ $resourceFile
+}
+
+function Add-RustFlag {
+ param(
+ [string]$RustFlags,
+ [Parameter(Mandatory)][string]$Flag
+ )
+
+ if ([string]::IsNullOrWhiteSpace($RustFlags)) {
+ return $Flag
+ }
+
+ if ($RustFlags -match [regex]::Escape($Flag)) {
+ return $RustFlags
+ }
+
+ "$RustFlags $Flag"
+}
+
+function Convert-PeRvaToOffset {
+ param(
+ [Parameter(Mandatory)][object[]]$Sections,
+ [Parameter(Mandatory)][uint32]$Rva
+ )
+
+ foreach ($section in $Sections) {
+ $size = [Math]::Max($section.VirtualSize, $section.RawDataSize)
+ if (($Rva -ge $section.VirtualAddress) -and ($Rva -lt ($section.VirtualAddress + $size))) {
+ return [int]($section.RawDataPointer + ($Rva - $section.VirtualAddress))
+ }
+ }
+
+ $null
+}
+
+function Get-NullTerminatedAsciiString {
+ param(
+ [Parameter(Mandatory)][byte[]]$Bytes,
+ [Parameter(Mandatory)][int]$Offset
+ )
+
+ $end = $Offset
+ while (($end -lt $Bytes.Length) -and ($Bytes[$end] -ne 0)) {
+ $end++
+ }
+
+ [System.Text.Encoding]::ASCII.GetString($Bytes, $Offset, $end - $Offset)
+}
+
+function Get-PeImportedDllNames {
+ param([Parameter(Mandatory)][string]$Path)
+
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
+ $peOffset = [BitConverter]::ToUInt32($bytes, 0x3c)
+ $coffOffset = [int]$peOffset + 4
+ $sectionCount = [BitConverter]::ToUInt16($bytes, $coffOffset + 2)
+ $optionalHeaderSize = [BitConverter]::ToUInt16($bytes, $coffOffset + 16)
+ $optionalHeaderOffset = $coffOffset + 20
+ $magic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset)
+ $dataDirectoryOffset = if ($magic -eq 0x20b) { $optionalHeaderOffset + 112 } elseif ($magic -eq 0x10b) { $optionalHeaderOffset + 96 } else { throw "Unsupported PE optional header magic: 0x$($magic.ToString('x'))" }
+ $importDirectoryRva = [BitConverter]::ToUInt32($bytes, $dataDirectoryOffset + 8)
+
+ if ($importDirectoryRva -eq 0) {
+ return @()
+ }
+
+ $sectionTableOffset = $optionalHeaderOffset + $optionalHeaderSize
+ $sections = @()
+ for ($index = 0; $index -lt $sectionCount; $index++) {
+ $sectionOffset = $sectionTableOffset + ($index * 40)
+ $sections += [pscustomobject]@{
+ VirtualSize = [BitConverter]::ToUInt32($bytes, $sectionOffset + 8)
+ VirtualAddress = [BitConverter]::ToUInt32($bytes, $sectionOffset + 12)
+ RawDataSize = [BitConverter]::ToUInt32($bytes, $sectionOffset + 16)
+ RawDataPointer = [BitConverter]::ToUInt32($bytes, $sectionOffset + 20)
+ }
+ }
+
+ $importDirectoryOffset = Convert-PeRvaToOffset -Sections $sections -Rva $importDirectoryRva
+ if ($null -eq $importDirectoryOffset) {
+ throw "Unable to resolve import directory RVA for $Path."
+ }
+
+ $importedDlls = @()
+ for ($descriptorOffset = $importDirectoryOffset; ; $descriptorOffset += 20) {
+ $originalFirstThunk = [BitConverter]::ToUInt32($bytes, $descriptorOffset)
+ $timeDateStamp = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 4)
+ $forwarderChain = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 8)
+ $nameRva = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 12)
+ $firstThunk = [BitConverter]::ToUInt32($bytes, $descriptorOffset + 16)
+
+ if (($originalFirstThunk -eq 0) -and ($timeDateStamp -eq 0) -and ($forwarderChain -eq 0) -and ($nameRva -eq 0) -and ($firstThunk -eq 0)) {
+ break
+ }
+
+ $nameOffset = Convert-PeRvaToOffset -Sections $sections -Rva $nameRva
+ if ($null -eq $nameOffset) {
+ throw "Unable to resolve import name RVA for $Path."
+ }
+
+ $importedDlls += Get-NullTerminatedAsciiString -Bytes $bytes -Offset $nameOffset
+ }
+
+ $importedDlls
+}
+
+function Assert-WindowsExecutableMetadata {
+ param(
+ [Parameter(Mandatory)][string]$Path,
+ [Parameter(Mandatory)][string]$ExpectedOriginalFilename
+ )
+
+ if (-not $IsWindows) {
+ return
+ }
+
+ $versionInfo = (Get-Item -Path $Path).VersionInfo
+ $requiredFields = @{
+ CompanyName = 'Devolutions Inc.'
+ FileDescription = 'Pinget CLI'
+ OriginalFilename = $ExpectedOriginalFilename
+ ProductName = 'Pinget'
+ }
+
+ foreach ($field in $requiredFields.GetEnumerator()) {
+ $actualValue = $versionInfo.PSObject.Properties[$field.Key].Value
+ if ($actualValue -ne $field.Value) {
+ throw "Unexpected $($field.Key) in $Path. Expected '$($field.Value)', got '$actualValue'."
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($versionInfo.FileVersion) -or [string]::IsNullOrWhiteSpace($versionInfo.ProductVersion)) {
+ throw "Missing Windows file/product version information in $Path."
+ }
+
+ $dynamicMsvcRuntimeImports = @(Get-PeImportedDllNames -Path $Path | Where-Object {
+ $_ -match '^(vcruntime|msvcp|msvcr|ucrtbase)' -or $_ -match '^api-ms-win-crt-'
+ })
+
+ if ($dynamicMsvcRuntimeImports.Count -gt 0) {
+ throw "Expected static MSVC/UCRT runtime linkage for $Path, but found dynamic imports: $($dynamicMsvcRuntimeImports -join ', ')"
+ }
+}
+
+function Get-SourceVersion {
+ $rustCliManifest = Join-Path $repoRoot 'rust\crates\pinget-cli\Cargo.toml'
+ $dotNetCliProgram = Join-Path $repoRoot 'dotnet\src\Devolutions.Pinget.Cli\Program.cs'
+
+ $rustMatch = Select-String -Path $rustCliManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1
+ $dotNetMatch = Select-String -Path $dotNetCliProgram -Pattern '^const string Version = "([^"]+)";$' | Select-Object -First 1
+
+ if (($null -eq $rustMatch) -or ($null -eq $dotNetMatch)) {
+ throw 'Unable to detect CLI package version from source files.'
+ }
+
+ $rustVersion = $rustMatch.Matches[0].Groups[1].Value
+ $dotNetVersion = $dotNetMatch.Matches[0].Groups[1].Value
+ if ($rustVersion -ne $dotNetVersion) {
+ throw "CLI version mismatch detected: rust=$rustVersion dotnet=$dotNetVersion"
+ }
+
+ $rustVersion
+}
+
+function Resolve-RustTarget {
+ param([Parameter(Mandatory)][string]$RuntimeIdentifier)
+
+ switch ($RuntimeIdentifier) {
+ 'win-x64' { @{ CargoTarget = 'x86_64-pc-windows-msvc'; SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } }
+ 'win-arm64' { @{ CargoTarget = 'aarch64-pc-windows-msvc'; SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } }
+ 'linux-x64' { @{ CargoTarget = 'x86_64-unknown-linux-gnu'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } }
+ 'linux-arm64' { @{ CargoTarget = 'aarch64-unknown-linux-gnu'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } }
+ 'osx-x64' { @{ CargoTarget = 'x86_64-apple-darwin'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } }
+ 'osx-arm64' { @{ CargoTarget = 'aarch64-apple-darwin'; SourceBinaryName = 'pinget'; PackageBinaryName = 'pinget' } }
+ default { throw "Unsupported Rust runtime identifier: $RuntimeIdentifier" }
+ }
+}
+
+function Get-DotNetBinaryNames {
+ param([Parameter(Mandatory)][string]$RuntimeIdentifier)
+
+ switch ($RuntimeIdentifier) {
+ 'win-x64' { @{ SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } }
+ 'win-arm64' { @{ SourceBinaryName = 'pinget.exe'; PackageBinaryName = 'pinget.exe' } }
+ default { throw "Unsupported .NET NativeAOT runtime identifier: $RuntimeIdentifier" }
+ }
+}
+
+function Assert-FileExists {
+ param([Parameter(Mandatory)][string]$Path)
+
+ if (-not (Test-Path -Path $Path -PathType Leaf)) {
+ throw "Expected file was not found: $Path"
+ }
+}
+
+function Set-NupkgUnixExecutablePermissions {
+ param([Parameter(Mandatory)][string]$PackagePath)
+
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+ $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, [System.IO.Compression.ZipArchiveMode]::Update)
+ try {
+ foreach ($entry in $archive.Entries) {
+ if ($entry.FullName -match '^runtimes/(linux|osx)-[^/]+/native/pinget$') {
+ $entry.ExternalAttributes = -2115174400 # 0o100755 << 16 as a signed Int32.
+ }
+ }
+ }
+ finally {
+ $archive.Dispose()
+ }
+}
+
+function Assert-DotNetToolPackage {
+ param([Parameter(Mandatory)][string]$PackagePath)
+
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath)
+ try {
+ $entryNames = @($archive.Entries | ForEach-Object { $_.FullName })
+ $nuspecEntry = $archive.Entries | Where-Object { $_.FullName -match '\.nuspec$' } | Select-Object -First 1
+ if ($null -eq $nuspecEntry) {
+ throw "Unable to find nuspec entry in $PackagePath."
+ }
+
+ $reader = New-Object System.IO.StreamReader($nuspecEntry.Open())
+ try {
+ $nuspec = $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Dispose()
+ }
+
+ if ($nuspec -notmatch ')[^<]+()' `
+ -Replacement { param($match) "$($match.Groups[1].Value)$Version$($match.Groups[2].Value)" }
+
+Set-VersionInFile `
+ -RelativePath 'nuget\Devolutions.Pinget.Cli.DotNet\Devolutions.Pinget.Cli.DotNet.csproj' `
+ -Pattern '^( )[^<]+()' `
+ -Replacement { param($match) "$($match.Groups[1].Value)$Version$($match.Groups[2].Value)" }