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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,34 @@ Pinget is designed to keep **source-backed functionality** working cross-platfor
- `list` and upgrade inventory return empty results with an unsupported warning
- `install`, `uninstall`, executed `upgrade`, and non-dry-run `import` return explicit no-op results with unsupported warnings

## Storage location and source mode

Two environment variables control where Pinget keeps its state and which sources it resolves
against. Both are read by the Rust CLI and the C# core.

| Variable | Effect |
| --- | --- |
| `PINGET_APPROOT` | Overrides the per-user storage root (`%LOCALAPPDATA%\Devolutions\Pinget` by default). Useful for embedding Pinget in a portable or sandboxed host. |
| `PINGET_SOURCE_MODE` | Selects the source mode explicitly: `auto`, `private`, or `system-winget-mirror`. |

Source modes:

- `auto` mirrors the machine's configured WinGet sources, falling back to the private store when
they cannot be read.
- `private` uses only the source list stored under the app root, seeded with the stock `winget`
and `msstore` entries.
- `system-winget-mirror` mirrors the machine's WinGet sources with no fallback.

Without `PINGET_SOURCE_MODE`, setting `PINGET_APPROOT` also selects `private`, on the assumption
that a caller supplying its own root wants an isolated instance. A host that relocates storage but
still wants the machine's real source list must set `PINGET_SOURCE_MODE=auto` as well; otherwise
sources the user added to WinGet are silently absent.

Precedence is the same in both implementations: an explicitly chosen mode wins, then
`PINGET_SOURCE_MODE`, then the app-root inference. In the C# API `RepositoryOptions.SourceMode` is
nullable for exactly this reason — leave it unset to let the environment decide, and note that
setting it to `SourceMode.Auto` is an explicit choice that the environment will not override.

## Custom REST sources

Both implementations support custom REST sources, including third-party services such as `winget.pro`.
Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Devolutions.Pinget.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using Devolutions.Pinget.Cli;
using Devolutions.Pinget.Core;

const string Version = "0.10.0";
const string Version = "0.11.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)))
Expand Down
130 changes: 130 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@

namespace Devolutions.Pinget.Core.Tests;

[CollectionDefinition(Name, DisableParallelization = true)]
public class RepositoryStateCollection
{
public const string Name = "RepositoryState";
}

public class VersionCompareTests
{
[Theory]
Expand Down Expand Up @@ -112,6 +118,7 @@ public void SelectRestVersion_WithRequestedChannelMismatch_Throws()
}
}

[Collection(RepositoryStateCollection.Name)]
public class SourceStoreTests
{
[Fact]
Expand Down Expand Up @@ -365,6 +372,127 @@ public void PackagedSecureSettingsStub_DelegatesToSystemWingetExport()
}
}

[Theory]
[InlineData("auto", SourceMode.Auto)]
[InlineData(" Auto ", SourceMode.Auto)]
[InlineData("PRIVATE", SourceMode.Private)]
[InlineData("system-winget-mirror", SourceMode.SystemWingetMirror)]
[InlineData("SystemWingetMirror", SourceMode.SystemWingetMirror)]
public void TryParseSourceMode_AcceptsKnownSpellings(string value, SourceMode expected)
{
Assert.True(Repository.TryParseSourceMode(value, out var parsed));
Assert.Equal(expected, parsed);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("mirror")]
[InlineData("system_winget_mirror")]
public void TryParseSourceMode_RejectsUnknownSpellings(string? value)
{
Assert.False(Repository.TryParseSourceMode(value, out _));
}

[Fact]
public void ResolveRequestedSourceMode_KeepsPrivateDefaultForACustomAppRoot()
{
Assert.Equal(
SourceMode.Private,
Repository.ResolveRequestedSourceMode(null, @"C:\custom", null));
Assert.Equal(
SourceMode.Auto,
Repository.ResolveRequestedSourceMode(null, null, null));
}

[Fact]
public void ResolveRequestedSourceMode_LetsTheEnvironmentOverrideACustomAppRoot()
{
Assert.Equal(
SourceMode.Auto,
Repository.ResolveRequestedSourceMode(null, @"C:\custom", "auto"));
Assert.Equal(
SourceMode.SystemWingetMirror,
Repository.ResolveRequestedSourceMode(null, @"C:\custom", "system-winget-mirror"));
Assert.Equal(
SourceMode.Private,
Repository.ResolveRequestedSourceMode(null, null, "private"));
}

[Fact]
public void ResolveRequestedSourceMode_IgnoresTheEnvironmentWhenTheCallerIsExplicit()
{
Assert.Equal(
SourceMode.SystemWingetMirror,
Repository.ResolveRequestedSourceMode(SourceMode.SystemWingetMirror, @"C:\custom", "private"));
Assert.Equal(
SourceMode.Private,
Repository.ResolveRequestedSourceMode(SourceMode.Private, null, "auto"));
}

[Fact]
public void ResolveRequestedSourceMode_TreatsAnExplicitAutoAsAChoiceRatherThanAnUnsetValue()
{
Assert.Equal(
SourceMode.Auto,
Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "private"));
Assert.Equal(
SourceMode.Auto,
Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", null));
}

[Fact]
public void ResolveRequestedSourceMode_IgnoresAnUnparseableEnvironmentValue()
{
Assert.Equal(
SourceMode.Private,
Repository.ResolveRequestedSourceMode(null, @"C:\custom", "not-a-mode"));
Assert.Equal(
SourceMode.Auto,
Repository.ResolveRequestedSourceMode(null, null, "not-a-mode"));
}

[Theory]
[InlineData(null, false)]
[InlineData("auto", true)]
[InlineData("system-winget-mirror", true)]
[InlineData("private", false)]
[InlineData("not-a-mode", false)]
public void RepositoryOpen_ReadsTheSourceModeEnvironmentVariable(string? sourceMode, bool expectsMirror)
{
var appRoot = TestPaths.CreateTempAppRoot();
var originalRunner = SystemWingetSourceStore.CommandRunner;
var originalSourceMode = Environment.GetEnvironmentVariable("PINGET_SOURCE_MODE");
try
{
SystemWingetSourceStore.CommandRunner = _ => new WingetCommandResult(
0,
"""
{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
""",
"");
Environment.SetEnvironmentVariable("PINGET_SOURCE_MODE", sourceMode);

using var repo = Repository.Open(new RepositoryOptions
{
AppRoot = appRoot,
UserAgent = "pinget-dotnet-tests/1.0",
});

var mirrorPath = Path.Combine(appRoot, "system-sources.json");
Assert.Equal(expectsMirror, File.Exists(mirrorPath));

if (expectsMirror)
Assert.Contains("contoso", File.ReadAllText(mirrorPath));
}
finally
{
SystemWingetSourceStore.CommandRunner = originalRunner;
Environment.SetEnvironmentVariable("PINGET_SOURCE_MODE", originalSourceMode);
TestPaths.DeleteAppRoot(appRoot);
}
}

[Fact]
public void RepositoryOpen_UsesCustomAppRoot()
{
Expand Down Expand Up @@ -1177,6 +1305,7 @@ PRIMARY KEY (package_id, source_id)
}
}

[Collection(RepositoryStateCollection.Name)]
public class RepositoryParityTests
{
[Fact]
Expand Down Expand Up @@ -2992,6 +3121,7 @@ public void CreateRepairInstallRequest_ForcesReinstallOfResolvedInstalledPackage
}
}

[Collection(RepositoryStateCollection.Name)]
public class RepositoryEmbeddingTests
{
private const string TesslPackageId = "tessl.tessl";
Expand Down
7 changes: 6 additions & 1 deletion dotnet/src/Devolutions.Pinget.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ public record RepositoryOptions
/// Storage root for Pinget state and caches.
/// </summary>
public string? AppRoot { get; init; }
public SourceMode SourceMode { get; init; } = SourceMode.Auto;
/// <summary>
/// Source resolution mode. Leave unset to let the app root and the
/// <c>PINGET_SOURCE_MODE</c> environment variable decide; any explicit value,
/// including <see cref="Core.SourceMode.Auto"/>, wins over both.
/// </summary>
public SourceMode? SourceMode { get; init; }
public string UserAgent { get; init; } = "pinget-dotnet/0.1";
public Action<RepositoryWarning>? Diagnostics { get; init; }

Expand Down
45 changes: 42 additions & 3 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Devolutions.Pinget.Core;
public class Repository : IDisposable
{
private const string AppRootEnvironmentVariable = "PINGET_APPROOT";
private const string SourceModeEnvironmentVariable = "PINGET_SOURCE_MODE";

internal const string InstalledStateUnsupportedWarning = "Installed package discovery is not supported on this platform; returning no installed packages.";
internal const string InstallUnsupportedWarning = "Installing packages is not supported on this platform; no changes were made.";
Expand Down Expand Up @@ -65,9 +66,7 @@ public static Repository Open(RepositoryOptions? options = null)
var requestedAppRoot = options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable);
var appRoot = SourceStoreManager.NormalizeAppRoot(requestedAppRoot);
SourceStoreManager.EnsureAppDirs(appRoot);
var requestedSourceMode = options.SourceMode == SourceMode.Auto && requestedAppRoot is not null
? SourceMode.Private
: options.SourceMode;
var requestedSourceMode = ResolveRequestedSourceMode(options.SourceMode, requestedAppRoot);
var (sourceMode, store) = SourceStoreManager.LoadEffective(appRoot, requestedSourceMode);
var client = new HttpClient(new SocketsHttpHandler
{
Expand All @@ -79,6 +78,46 @@ public static Repository Open(RepositoryOptions? options = null)
return new Repository(appRoot, client, store, sourceMode, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval);
}

internal static SourceMode ResolveRequestedSourceMode(SourceMode? configuredSourceMode, string? requestedAppRoot) =>
ResolveRequestedSourceMode(
configuredSourceMode,
requestedAppRoot,
Environment.GetEnvironmentVariable(SourceModeEnvironmentVariable));

internal static SourceMode ResolveRequestedSourceMode(
SourceMode? configuredSourceMode,
string? requestedAppRoot,
string? sourceModeFromEnvironment)
{
if (configuredSourceMode is { } explicitSourceMode)
return explicitSourceMode;

if (TryParseSourceMode(sourceModeFromEnvironment, out var parsedSourceMode))
return parsedSourceMode;

return requestedAppRoot is null ? SourceMode.Auto : SourceMode.Private;
}

internal static bool TryParseSourceMode(string? value, out SourceMode sourceMode)
{
switch (value?.Trim().ToLowerInvariant())
{
case "auto":
sourceMode = SourceMode.Auto;
return true;
case "private":
sourceMode = SourceMode.Private;
return true;
case "system-winget-mirror":
case "systemwingetmirror":
sourceMode = SourceMode.SystemWingetMirror;
return true;
default:
sourceMode = SourceMode.Auto;
return false;
}
}

internal static IEnumerable<string> GetSqliteNativeLibraryCandidates(string assemblyDirectory)
{
yield return Path.Combine(assemblyDirectory, "e_sqlite3.dll");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'Devolutions.Pinget.Client.psm1'
ModuleVersion = '0.10.0'
ModuleVersion = '0.11.0'
CompatiblePSEditions = @('Desktop', 'Core')
GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd'
Author = 'Devolutions'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine;

public static class PowerShellEngineVersion
{
public const string Current = "0.10.0";
public const string Current = "0.11.0";
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk" DefaultTargets="Pack">

<PropertyGroup>
<Version>0.10.0</Version>
<Version>0.11.0</Version>
<Company>Devolutions Inc.</Company>
<Authors>Devolutions</Authors>
<PackageId>Devolutions.Pinget.Cli.DotNet</PackageId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk" DefaultTargets="Pack">

<PropertyGroup>
<Version>0.10.0</Version>
<Version>0.11.0</Version>
<Company>Devolutions Inc.</Company>
<Authors>Devolutions</Authors>
<PackageId>Devolutions.Pinget.Cli.Rust</PackageId>
Expand Down
4 changes: 2 additions & 2 deletions rust/crates/pinget-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pinget-cli"
version = "0.10.0"
version = "0.11.0"
edition = "2024"

[lints]
Expand All @@ -13,7 +13,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
pinget-core = { version = "0.10.0", path = "../pinget-core" }
pinget-core = { version = "0.11.0", path = "../pinget-core" }
chrono = "0.4.44"
dirs = "6.0"
jsonschema = { version = "0.30", default-features = false, features = ["resolve-file"] }
Expand Down
Loading
Loading