(feature): persist operator settings in appdata; sync repoutils secret packs

This commit is contained in:
Maksym Sadovnychyy 2026-09-03 08:04:09 +02:00
parent 7da30fd459
commit 0ee07f7246
20 changed files with 838 additions and 374 deletions

View File

@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.6.2] - 2026-09-03
### Changed
- Operator settings (layout, port-forwards, Chat) are written to `%AppData%/MaksIT/Cluster Console/settings.json` (WiX `installFolderName`). Shipped `appsettings.json` next to the exe keeps host logging only; a leftover `Configuration` block is copied once into the user file.
- Synced RepoUtils: ContainerRegistry JSON catalog (PascalCase Harbor / InCluster keys) and `RepoUtilsSecrets` pack slots instead of per-plugin `*Secret` env names.
## [0.6.1] - 2026-08-30
### Changed
@ -133,3 +140,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Helm releases, Dapr CRDs, Applications view, force-delete, volume file browse, resource-limit patches, and a read-only local Ollama **Chat** tab.
See [README.md](README.md) for the full feature list.

View File

@ -61,7 +61,9 @@ Connect a context from the catalog, pick a navigator item, then use the table, d
## Configuration
Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI). Notable keys under `Configuration`:
Host logging lives in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI under Program Files; normal users cannot write it). Operator layout, open clusters, port-forwards, and Chat settings are saved to `%AppData%/MaksIT/Cluster Console/settings.json` (same folder name as WiX: `Program Files\MaksIT\Cluster Console`). On first launch, a leftover `Configuration` block next to the exe is copied once into that user file.
Notable keys under `Configuration` in the user file:
| Key | Role |
|-----|------|

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.6.1</Version>
<Version>0.6.2</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -5,20 +5,36 @@ using System.Text.Json.Nodes;
namespace MaksIT.ClusterConsole.Shared;
public sealed class ConfigurationFileService {
public const string ProductFolder = "Cluster Console";
public const string SeedFileName = "appsettings.json";
private static readonly JsonSerializerOptions SerializerOptions = new() {
WriteIndented = true,
PropertyNamingPolicy = null
};
private readonly string? _seedPath;
private Configuration _current;
public string FilePath { get; }
public Configuration Current => _current;
public ConfigurationFileService(string? configurationPath = null) {
FilePath = configurationPath ?? Path.Combine(AppContext.BaseDirectory, "appsettings.json");
public ConfigurationFileService(string? configurationPath = null, string? seedPath = null) {
if (!string.IsNullOrWhiteSpace(configurationPath))
FilePath = configurationPath;
else
FilePath = UserSettingsPath.Get(ProductFolder);
if (!string.IsNullOrWhiteSpace(seedPath))
_seedPath = seedPath;
else if (string.IsNullOrWhiteSpace(configurationPath))
_seedPath = Path.Combine(AppContext.BaseDirectory, SeedFileName);
else
_seedPath = null;
_current = LoadFromDisk();
CopySeedIfNeeded();
}
public Configuration Reload() {
@ -29,24 +45,23 @@ public sealed class ConfigurationFileService {
public void Save(Configuration configuration) {
ArgumentNullException.ThrowIfNull(configuration);
configuration.EnsureDefaults();
JsonObject root;
if (File.Exists(FilePath)) {
root = JsonNode.Parse(File.ReadAllText(FilePath)) as JsonObject ?? [];
}
else {
root = [];
}
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var root = ReadRoot(File.Exists(FilePath) ? FilePath : null) ?? [];
root["Configuration"] = JsonSerializer.SerializeToNode(configuration, SerializerOptions);
File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions));
_current = configuration;
}
private Configuration LoadFromDisk() {
if (!File.Exists(FilePath))
var path = ResolveReadPath();
if (path is null)
return new Configuration();
using var document = JsonDocument.Parse(File.ReadAllText(FilePath));
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (!document.RootElement.TryGetProperty("Configuration", out var value))
return new Configuration();
@ -54,4 +69,31 @@ public sealed class ConfigurationFileService {
configuration.EnsureDefaults();
return configuration;
}
private static JsonObject? ReadRoot(string? path) {
if (path is null || !File.Exists(path))
return null;
return JsonNode.Parse(File.ReadAllText(path)) as JsonObject;
}
private void CopySeedIfNeeded() {
if (File.Exists(FilePath) || _seedPath is null || !File.Exists(_seedPath) || !HasConfiguration(_seedPath))
return;
Save(_current);
}
private static bool HasConfiguration(string path) {
using var document = JsonDocument.Parse(File.ReadAllText(path));
return document.RootElement.TryGetProperty("Configuration", out _);
}
private string? ResolveReadPath() {
if (File.Exists(FilePath))
return FilePath;
if (_seedPath is not null && File.Exists(_seedPath))
return _seedPath;
return null;
}
}

View File

@ -0,0 +1,17 @@
namespace MaksIT.ClusterConsole.Shared;
/// <summary>
/// User-writable operator settings under AppData. The product folder must match
/// the WiX <c>installFolderName</c> (or <c>Get-DesktopInstallFolderName</c> from
/// <c>appName</c> + <c>manufacturer</c>), e.g. <c>%AppData%/MaksIT/Cluster Console</c>.
/// Host logging stays in shipped <c>appsettings.json</c> next to the exe.
/// </summary>
public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MaksIT",
product,
fileName);
}

View File

@ -5,22 +5,5 @@
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Configuration": {
"SelectedNamespace": "all",
"OllamaEndpoint": "http://127.0.0.1:11434",
"OllamaModel": "qwen3:8b",
"NavigatorExpanded": {},
"Layout": {
"WindowWidth": 1400,
"WindowHeight": 860,
"WindowState": "Normal",
"CatalogWidth": 248,
"NavigatorWidth": 228,
"DetailsWidth": 380,
"SelectedNavId": "pods",
"Tables": {}
},
"PortForwards": []
}
}

View File

@ -247,9 +247,76 @@ public class ConfigurationFileServiceTests {
}
[Fact]
public void Default_path_is_appsettings_beside_the_executable() {
public void Default_path_is_appdata_under_maksit() {
var service = new ConfigurationFileService();
Assert.Equal(Path.Combine(AppContext.BaseDirectory, "appsettings.json"), service.FilePath);
Assert.Equal(UserSettingsPath.Get(ConfigurationFileService.ProductFolder), service.FilePath);
}
[Fact]
public void Save_to_new_file_writes_only_configuration() {
var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json");
try {
var service = new ConfigurationFileService(path);
service.Save(new Configuration { SelectedNamespace = "kube-system" });
var json = File.ReadAllText(path);
Assert.Contains("\"Configuration\"", json, StringComparison.Ordinal);
Assert.DoesNotContain("\"Logging\"", json, StringComparison.Ordinal);
Assert.Equal("kube-system", new ConfigurationFileService(path).Current.SelectedNamespace);
}
finally {
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public void Copies_seed_configuration_without_logging() {
var dir = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
var seed = Path.Combine(dir, "appsettings.json");
var user = Path.Combine(dir, "settings.json");
File.WriteAllText(seed, """
{
"Logging": { "LogLevel": { "Default": "Information" } },
"Configuration": { "SelectedNamespace": "kube-system" }
}
""");
try {
var service = new ConfigurationFileService(user, seed);
Assert.True(File.Exists(user));
Assert.Equal("kube-system", service.Current.SelectedNamespace);
var json = File.ReadAllText(user);
Assert.Contains("\"Configuration\"", json, StringComparison.Ordinal);
Assert.DoesNotContain("\"Logging\"", json, StringComparison.Ordinal);
}
finally {
Directory.Delete(dir, true);
}
}
[Fact]
public void Logging_only_seed_does_not_create_user_file() {
var dir = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
var seed = Path.Combine(dir, "appsettings.json");
var user = Path.Combine(dir, "settings.json");
File.WriteAllText(seed, """
{
"Logging": { "LogLevel": { "Default": "Information" } }
}
""");
try {
var service = new ConfigurationFileService(user, seed);
Assert.False(File.Exists(user));
Assert.Equal(Configuration.AllNamespaces, service.Current.SelectedNamespace);
}
finally {
Directory.Delete(dir, true);
}
}
[Fact]

View File

@ -22,6 +22,10 @@ public partial class App : Application {
.ConfigureAppConfiguration(builder => {
builder.SetBasePath(AppContext.BaseDirectory);
builder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
builder.AddJsonFile(
UserSettingsPath.Get(ConfigurationFileService.ProductFolder),
optional: true,
reloadOnChange: true);
})
.ConfigureServices((_, services) => {
services.AddSingleton(_ => new ConfigurationFileService());

View File

@ -98,9 +98,9 @@
},
{
"name": "GitHub",
"githubSecret": "GitClone",
"stageLabel": "release",
"enabled": true,
"githubSecret": "GitHub",
"repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}",
@ -119,5 +119,8 @@
"*.flatpak"
]
}
]
],
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"vaultRepoUtilsApplication": "Shared"
}

View File

@ -46,5 +46,8 @@
"red": 0
}
}
]
],
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"vaultRepoUtilsApplication": "Shared"
}

View File

@ -294,129 +294,586 @@ function Test-PluginMutatesRemote {
return $false
}
function Get-SecretEnvironmentValue {
function Get-RepoUtilsEnvironmentVariable {
<#
.SYNOPSIS
Reads a secret value from an environment variable by logical name.
Reads a named environment variable from Process, then Windows User, then Machine.
.DESCRIPTION
Plugins never store secret material in scriptSettings.json. Settings hold a
logical name (e.g. "GitHub", "NuGet"); the process environment variable with
that same name must be set before the engine runs.
.PARAMETER Name
Logical secret name — also the environment variable name to read.
.OUTPUTS
System.String. The environment variable value, or $null when unset.
.EXAMPLE
$token = Get-SecretEnvironmentValue -Name 'GitHub'
Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the
current process was started before a User-level pack was set, User/Machine still
apply so laptop releases do not require a new shell. An explicit process value —
including empty JSON `{}` — shadows User/Machine.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
return [Environment]::GetEnvironmentVariable($Name)
}
if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Environment variable name is required."
}
function Resolve-PluginSecretName {
<#
.SYNOPSIS
Resolves a logical secret name from a plugin's scriptSettings entry.
$processValue = [Environment]::GetEnvironmentVariable($Name, 'Process')
# Empty string is what SetEnvironmentVariable($null, Process) leaves behind;
# treat it as unset so Windows User packs still apply. Explicit '{}' shadows User.
if (-not [string]::IsNullOrWhiteSpace($processValue)) {
return $processValue
}
.DESCRIPTION
Reads a string property such as githubSecret / nugetSecret / npmSecret /
containerRegistrySecret from the plugin settings object. Returns $null when
the property is missing or blank.
foreach ($target in @('User', 'Machine')) {
try {
$value = [Environment]::GetEnvironmentVariable($Name, $target)
}
catch {
continue
}
.PARAMETER PluginSettings
Plugin settings object from scriptSettings.json (the enabled plugin entry).
.PARAMETER PropertyName
Settings property that holds the logical secret name (e.g. 'githubSecret').
.OUTPUTS
System.String. Trimmed logical secret name, or $null.
.EXAMPLE
$name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret'
$key = Get-SecretEnvironmentValue -Name $name
#>
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$value = [string]$PluginSettings.$PropertyName
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
return $value
}
}
return $null
}
function Get-RepoUtilsSecretsEnvNames {
<#
.SYNOPSIS
Reads declared pack environment variable names from scriptSettings / engine context.
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$sharedName = $null
$packName = $null
if ($null -ne $Settings) {
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') {
$sharedName = [string]$Settings.repoUtilsSecretsShared
}
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') {
$packName = [string]$Settings.repoUtilsSecrets
}
}
$sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() }
$packName = if ($null -eq $packName) { '' } else { $packName.Trim() }
if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) {
throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)."
}
return [pscustomobject]@{
SharedEnv = $sharedName
PackEnv = $packName
}
}
function ConvertFrom-RepoUtilsSecretsPackJson {
<#
.SYNOPSIS
Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws.
#>
param(
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Raw,
[Parameter(Mandatory = $true)]
[string]$SourceName
)
if ([string]::IsNullOrWhiteSpace($Raw)) {
return [pscustomobject]@{}
}
$trimmed = $Raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string."
}
try {
$parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "${SourceName} is not valid JSON: $($_.Exception.Message)"
}
if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) {
throw "${SourceName} JSON must be an object."
}
return $parsed
}
function ConvertTo-OrdinalPropertyMap {
param($Object)
$map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
if ($null -eq $Object) {
return $map
}
if ($Object -is [System.Collections.IDictionary]) {
foreach ($key in @($Object.Keys)) {
$name = [string]$key
if ([string]::IsNullOrWhiteSpace($name)) {
continue
}
$map[$name] = $Object[$key]
}
return $map
}
foreach ($property in $Object.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$map[[string]$property.Name] = $property.Value
}
return $map
}
function ConvertFrom-OrdinalPropertyMap {
param(
[Parameter(Mandatory = $true)]
[System.Collections.Generic.Dictionary[string, object]]$Map
)
$properties = [ordered]@{}
foreach ($key in $Map.Keys) {
$properties[$key] = $Map[$key]
}
return [pscustomobject]$properties
}
function Merge-RepoUtilsSecretsPackObjects {
<#
.SYNOPSIS
Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry).
#>
param(
$Base,
$Overlay
)
$result = ConvertTo-OrdinalPropertyMap -Object $Base
$overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay
foreach ($key in @($overlayMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
$overlayValue = $overlayMap[$key]
$overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable])
if ($overlayIsObject) {
$baseCatalog = $null
if ($result.ContainsKey($key)) {
$baseCatalog = $result[$key]
}
$mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog
$overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue
foreach ($catalogKey in @($overlayCatalogMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) {
throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)."
}
$mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey]
}
$result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog
continue
}
$result[$key] = $overlayValue
}
foreach ($key in @($result.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
}
return ConvertFrom-OrdinalPropertyMap -Map $result
}
function Get-MergedRepoUtilsSecretsPack {
<#
.SYNOPSIS
Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings).
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv
$packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv
return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject
}
function Get-RepoUtilsSecretSlot {
<#
.SYNOPSIS
Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Settings,
[switch]$AllowMissing
)
if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) {
throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)."
}
$merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings
$match = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
if ($AllowMissing) {
return $null
}
throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack."
}
$value = $match.Value
if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) {
$text = if ($null -eq $value) { '' } else { [string]$value }
if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) {
throw "RepoUtilsSecrets slot '$Name' is empty."
}
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
return $text
}
throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)."
}
function Copy-RepoUtilsSecretsEnvNamesToContext {
param(
[Parameter(Mandatory = $true)]
$Context,
[Parameter(Mandatory = $false)]
$Settings
)
if ($null -eq $Settings) {
return $Context
}
foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) {
if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) {
$Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force
}
}
return $Context
}
function Test-ContainerRegistryCatalogKey {
<#
.SYNOPSIS
True when Key is PascalCase (Harbor, InCluster), matching env-slot names.
#>
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Key
)
return $Key -cmatch '^[A-Z][A-Za-z0-9]*$'
}
function Resolve-PluginSecretName {
<#
.SYNOPSIS
Reads a plugin setting that names a RepoUtilsSecrets pack subkey.
.DESCRIPTION
Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret /
cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet).
They are not environment variable names.
.PARAMETER PluginSettings
Plugin settings object from scriptSettings.json.
.PARAMETER PropertyName
Settings property that holds the pack subkey name (e.g. 'githubSecret').
.PARAMETER PluginDisplayName
Plugin name used in required/validation errors.
.PARAMETER Required
Throw when the property is missing or blank.
#>
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $false)]
[string]$PluginDisplayName,
[switch]$Required
)
$display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName }
$value = $null
if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$raw = [string]$PluginSettings.$PropertyName
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$value = $raw.Trim()
}
}
if ([string]::IsNullOrWhiteSpace($value)) {
if ($Required) {
throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)."
}
return $null
}
if (-not (Test-ContainerRegistryCatalogKey -Key $value)) {
throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)."
}
return $value
}
function Assert-RetiredContainerRegistrySettingsAbsent {
<#
.SYNOPSIS
Throws when obsolete per-registry secret settings are present.
#>
param(
$Object,
[Parameter(Mandatory = $true)]
[string]$Context
)
if ($null -eq $Object) {
return
}
$retired = @(
'imagesCredentialsSecret',
'additionalImageRegistries',
'additionalImageRegistryUrls',
'additionalImagesCredentialsSecret',
'helmOciCredentialsSecret'
)
foreach ($name in $retired) {
$present = $false
if ($Object -is [System.Collections.IDictionary]) {
$present = $Object.Contains($name)
}
elseif ($Object.PSObject.Properties.Name -contains $name) {
$present = $true
}
if ($present) {
throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey."
}
}
}
function ConvertFrom-RegistryCredentialPayload {
param(
[Parameter(Mandatory = $true)]
[string]$Payload,
[Parameter(Mandatory = $true)]
[string]$ContextName
)
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim()))
}
catch {
throw "Failed to decode '$ContextName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$ContextName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
function Get-ContainerRegistryCatalogObject {
<#
.SYNOPSIS
Validates a PascalCase JSON map of Base64(username:password) values.
#>
param(
[Parameter(Mandatory = $true)]
$Catalog,
[Parameter(Mandatory = $false)]
[string]$SourceName = 'ContainerRegistry'
)
if ($Catalog -is [string]) {
$raw = [string]$Catalog
$trimmed = $raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "$SourceName must be a JSON catalog object { `"Harbor`": `"<Base64>`", `"InCluster`": `"<Base64>`" }."
}
try {
$Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "$SourceName is not valid JSON: $($_.Exception.Message)"
}
}
if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) {
throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)."
}
$keyCount = 0
foreach ($property in $Catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$keyCount++
$keyName = [string]$property.Name
if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) {
throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)."
}
}
if ($keyCount -eq 0) {
throw "$SourceName JSON catalog has no PascalCase keys."
}
return $Catalog
}
function Get-RegistryCredentialsFromRuntime {
<#
.SYNOPSIS
Loads container-registry username/password from a logical secret name.
Loads container-registry username/password from the merged RepoUtilsSecrets pack.
.DESCRIPTION
Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by registry login and image-pull
secret creation — never pass the password itself as a parameter.
Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
.PARAMETER SecretName
Logical secret name (environment variable name), not a password or token.
.PARAMETER Key
PascalCase catalog key (e.g. Harbor). Required.
.PARAMETER Slot
PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
.PARAMETER SharedSettings
Optional engine shared context (reserved for callers that thread context).
Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
.EXAMPLE
$creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry'
# $creds.User / $creds.Password
#>
# SecretName is a logical env-var name from scriptSettings, not a password value.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSAvoidUsingPlainTextForPassword',
'SecretName',
Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.'
)]
param(
[Parameter(Mandatory = $true)]
[string]$SecretName,
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$Slot,
[Parameter(Mandatory = $false)]
[psobject]$SharedSettings
)
$raw = Get-SecretEnvironmentValue -Name $SecretName
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$SecretName' is not set."
if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
}
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
}
catch {
throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
if ([string]::IsNullOrWhiteSpace($Key)) {
throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$SecretName' must be in the form 'username:password'."
if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
}
return @{ User = $parts[0]; Password = $parts[1] }
$merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings
$catalogProperty = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) {
$catalogProperty = $property
break
}
}
if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) {
throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack."
}
$catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot
$match = $null
foreach ($property in $catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)."
}
$payload = [string]$match.Value
if ([string]::IsNullOrWhiteSpace($payload)) {
throw "Catalog key '$Key' in '$Slot' is empty."
}
return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key"
}
function Resolve-EngineDirectoryFromSharedSettings {
@ -757,4 +1214,4 @@ function Invoke-ConfiguredPlugin {
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -213,6 +213,7 @@ function New-EngineContext {
skipPublishPlugins = $false
facts = [ordered]@{}
}
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required)

View File

@ -38,6 +38,7 @@ function New-EngineContext {
utilsDir = $SrcDir
facts = [ordered]@{}
}
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) {

View File

@ -86,89 +86,23 @@ function Get-RepoUtilsVaultConnectionSecretName {
return 'MAKSIT_VAULT'
}
function Add-RepoUtilsSecretNamesFromObject {
param(
$Object,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[System.Collections.Generic.HashSet[string]]$Names
)
if ($null -eq $Object -or $Object -is [string] -or $Object -is [ValueType]) {
return
}
if ($Object -is [System.Collections.IDictionary]) {
foreach ($key in @($Object.Keys)) {
$name = [string]$key
$value = $Object[$key]
if ($name -like '*Secret') {
$trimmed = ([string]$value).Trim()
if (-not [string]::IsNullOrWhiteSpace($trimmed) -and
-not [string]::Equals($trimmed, 'WebhookSecret', [System.StringComparison]::Ordinal)) {
[void]$Names.Add($trimmed)
}
}
else {
Add-RepoUtilsSecretNamesFromObject -Object $value -Names $Names
}
}
return
}
if ($Object -is [System.Collections.IEnumerable]) {
foreach ($item in @($Object)) {
Add-RepoUtilsSecretNamesFromObject -Object $item -Names $Names
}
return
}
if ($null -eq $Object.PSObject) {
return
}
foreach ($property in $Object.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$value = $property.Value
if ($property.Name -like '*Secret') {
$trimmed = ([string]$value).Trim()
if (-not [string]::IsNullOrWhiteSpace($trimmed) -and
-not [string]::Equals($trimmed, 'WebhookSecret', [System.StringComparison]::Ordinal)) {
[void]$Names.Add($trimmed)
}
continue
}
Add-RepoUtilsSecretNamesFromObject -Object $value -Names $Names
}
}
function Get-EnabledPluginSecretNames {
function Get-RepoUtilsVaultOrgPackApplicationName {
param(
[Parameter(Mandatory = $true)]
$Plugins
$Settings
)
$names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($plugin in @($Plugins)) {
if ($null -eq $plugin) {
continue
}
if (($plugin.PSObject.Properties.Name -contains 'enabled') -and ($plugin.enabled -eq $false)) {
continue
}
Add-RepoUtilsSecretNamesFromObject -Object $plugin -Names $names
$application = $null
if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') {
$application = [string]$Settings.vaultRepoUtilsApplication
}
return @($names)
$application = if ($null -eq $application) { '' } else { $application.Trim() }
if ([string]::IsNullOrWhiteSpace($application)) {
throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)."
}
return $application
}
function Get-VaultNameFilterExpression {
@ -325,25 +259,23 @@ function Resolve-RepoUtilsVaultSecretValue {
[string]$Mode
)
foreach ($application in @($ApplicationName, 'Shared')) {
$match = Find-RepoUtilsVaultSecretMatch `
-OrganizationName $OrganizationName `
-ApplicationName $application `
-SecretName $SecretName `
-Connection $Connection `
-Mode $Mode
if ($null -eq $match) {
continue
}
$value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
if (-not [string]::IsNullOrWhiteSpace($value)) {
Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$application"
return $value
}
$match = Find-RepoUtilsVaultSecretMatch `
-OrganizationName $OrganizationName `
-ApplicationName $ApplicationName `
-SecretName $SecretName `
-Connection $Connection `
-Mode $Mode
if ($null -eq $match) {
return $null
}
return $null
$value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName"
return $value
}
function Initialize-RepoUtilsVaultSecrets {
@ -359,6 +291,18 @@ function Initialize-RepoUtilsVaultSecrets {
return
}
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw)
$packPresent = -not [string]::IsNullOrWhiteSpace($packRaw)
if ($sharedPresent -and $packPresent) {
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv)
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv)
Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch."
return
}
$connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings
$raw = [Environment]::GetEnvironmentVariable($connectionName)
if ([string]::IsNullOrWhiteSpace($raw)) {
@ -367,9 +311,9 @@ function Initialize-RepoUtilsVaultSecrets {
$connection = ConvertFrom-VaultConnectionSecret -Raw $raw
$scope = Get-RepoUtilsVaultScope -Settings $Settings
$secretNames = @(Get-EnabledPluginSecretNames -Plugins $Plugins)
$orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings
Write-Log -Level 'INFO' -Message "Vault mode: loading $($secretNames.Count) plugin secret(s) for $($scope.Organization)/$($scope.Application)"
Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)"
$mode = 'Rest'
try {
@ -388,18 +332,34 @@ function Initialize-RepoUtilsVaultSecrets {
$mode = 'Rest'
}
foreach ($secretName in $secretNames) {
$value = Resolve-RepoUtilsVaultSecretValue `
if (-not $sharedPresent) {
$sharedValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $scope.Application `
-SecretName $secretName `
-ApplicationName $orgPackApplication `
-SecretName $names.SharedEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Vault secret '$secretName' was not found for $($scope.Organization)/$($scope.Application) (or Shared) or has no current version."
if ([string]::IsNullOrWhiteSpace($sharedValue)) {
$sharedValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack."
}
[Environment]::SetEnvironmentVariable($secretName, $value, 'Process')
[Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process')
}
if (-not $packPresent) {
$packValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $scope.Application `
-SecretName $names.PackEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($packValue)) {
$packValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack."
}
[Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process')
}
}
@ -408,6 +368,6 @@ Export-ModuleMember -Function @(
'ConvertFrom-VaultConnectionSecret',
'Get-RepoUtilsVaultScope',
'Get-RepoUtilsVaultConnectionSecretName',
'Get-EnabledPluginSecretNames',
'Get-RepoUtilsVaultOrgPackApplicationName',
'Initialize-RepoUtilsVaultSecrets'
)

View File

@ -55,6 +55,37 @@ function Get-DesktopInstallFolderName {
return $name
}
function Get-WixArchitectureFromRuntimeIdentifier {
param(
[Parameter(Mandatory = $false)]
[string]$RuntimeIdentifier = ''
)
$rid = [string]$RuntimeIdentifier
if ($rid -match '(?i)arm64') {
return 'arm64'
}
if ($rid -match '(?i)(^|-)x86($|-)') {
return 'x86'
}
return 'x64'
}
function Get-WixPerMachineProgramFilesFolderId {
param(
[Parameter(Mandatory = $false)]
[string]$Architecture = 'x64'
)
if ($Architecture -eq 'x86') {
return 'ProgramFilesFolder'
}
return 'ProgramFiles64Folder'
}
function Get-MsiProductVersion {
param(
[Parameter(Mandatory = $true)]
@ -248,6 +279,9 @@ function New-WixPackageXml {
[Parameter(Mandatory = $false)]
[string]$InstallFolderName,
[Parameter(Mandatory = $false)]
[string]$Architecture = 'x64',
[Parameter(Mandatory = $false)]
[string]$IconPath
)
@ -297,7 +331,12 @@ function New-WixPackageXml {
-InstallFolderName $InstallFolderName
$stdLocal = $xml.CreateElement('StandardDirectory', $ns)
$rootFolderId = if ($scope -eq 'perMachine') { 'ProgramFiles6432Folder' } else { 'LocalAppDataFolder' }
$rootFolderId = if ($scope -eq 'perMachine') {
Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture
}
else {
'LocalAppDataFolder'
}
$null = $stdLocal.SetAttribute('Id', $rootFolderId)
$null = $package.AppendChild($stdLocal)
@ -890,7 +929,10 @@ function New-WixBundleXml {
[string]$InstallScope = 'perMachine',
[Parameter(Mandatory = $false)]
[string]$InstallFolderName
[string]$InstallFolderName,
[Parameter(Mandatory = $false)]
[string]$Architecture = 'x64'
)
$escapedName = [System.Security.SecurityElement]::Escape($AppName)
@ -923,7 +965,7 @@ function New-WixBundleXml {
}
# Type=formatted so WixStdBA expands well-known folders in the InstallFolder edit box.
# Type=string shows the raw token, e.g. [ProgramFiles6432Folder]MaksIT\Cluster Console.
# Type=string shows the raw token, e.g. [ProgramFiles64Folder]MaksIT\Cluster Console.
# Burn CSIDL folders already end with a backslash, so do not insert another one.
# Layout is {ProgramFiles|LocalAppData}\{Manufacturer}\{product} — product folder is the
# internal name (appName with manufacturer prefix stripped, or installFolderName).
@ -931,7 +973,7 @@ function New-WixBundleXml {
'[LocalAppDataFolder]'
}
else {
'[ProgramFiles6432Folder]'
'[' + (Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture) + ']'
}
$folderPath = if ([string]::IsNullOrWhiteSpace($Manufacturer)) {
@ -968,6 +1010,8 @@ Export-ModuleMember -Function `
ConvertTo-WixIdentifier, `
Get-MsiProductVersion, `
Get-DesktopInstallFolderName, `
Get-WixArchitectureFromRuntimeIdentifier, `
Get-WixPerMachineProgramFilesFolderId, `
Get-PluginPropertyValue, `
Resolve-DesktopPublishDirectory, `
Resolve-DesktopExecutablePath, `

View File

@ -9,6 +9,8 @@
Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps
it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS
stay in a staging folder and are not added to the portable zip.
`wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so
per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext`.
@ -102,6 +104,8 @@ function Invoke-Plugin {
$upgradeCode = [guid]$upgradeCodeRaw
$manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64')
$wixArch = Get-WixArchitectureFromRuntimeIdentifier -RuntimeIdentifier $runtimeIdentifier
$archArgs = @('-arch', $wixArch)
$installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perMachine')
$installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
@ -209,7 +213,7 @@ function Invoke-Plugin {
$msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi')
$bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs')
Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory"
Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)"
$xml = New-WixPackageXml `
-AppName $appName `
-Manufacturer $manufacturer `
@ -219,6 +223,7 @@ function Invoke-Plugin {
-ExecutablePath $executablePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName `
-Architecture $wixArch `
-IconPath $iconPath
$xml.Save($wxsPath)
@ -238,9 +243,9 @@ function Invoke-Plugin {
$eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion)
$bundleExt = Get-WixBundleExtensionName -VersionText $wixVersion
Write-Log -Level "STEP" -Message "Building MSI with WiX..."
Write-Log -Level "STEP" -Message "Building MSI with WiX ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($wxsPath, '-o', $msiPath)) | Out-Null
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($wxsPath, '-o', $msiPath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
@ -265,16 +270,17 @@ function Invoke-Plugin {
-LogoSidePath $logoSidePath `
-ThemePath $themePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName
-InstallFolderName $installFolderName `
-Architecture $wixArch
[System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $exePath -PathType Leaf) {
Remove-Item -LiteralPath $exePath -Force
}
Write-Log -Level "STEP" -Message "Building Windows installer exe..."
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {

View File

@ -1,122 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET artifact cleanup plugin — remove NuGet build outputs after release.
.DESCRIPTION
Removes files from the configured artifacts directory using glob patterns.
Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the
end of the Release stage after DotNetCreateArchive or publish plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-CleanupPatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @('*.nupkg', '*.snupkg')
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @('*.nupkg', '*.snupkg')
}
return @([string]$ConfiguredPatterns)
}
function Get-ExcludePatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @()
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @()
}
return @([string]$ConfiguredPatterns)
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$artifactsDirectory = $sharedSettings.artifactsDirectory
$patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns
$excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory"
return
}
Write-Log -Level "STEP" -Message "Cleaning generated artifacts..."
$itemsToRemove = @()
foreach ($pattern in $patterns) {
$matchedItems = @(
Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern }
)
if ($excludePatterns.Count -gt 0) {
$matchedItems = @(
$matchedItems |
Where-Object {
$item = $_
-not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1)
}
)
}
$itemsToRemove += @($matchedItems)
}
$itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique)
if ($itemsToRemove.Count -eq 0) {
Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules."
return
}
foreach ($item in $itemsToRemove) {
Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue
Write-Log -Level "OK" -Message " Removed: $($item.Name)"
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -29,10 +29,10 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required
Assert-Command dotnet
@ -52,13 +52,9 @@ function Invoke-Plugin {
return
}
if ([string]::IsNullOrWhiteSpace($nugetSecret)) {
throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
}
$nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
$nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($nugetKey)) {
throw "NuGet API key is not set. Set environment variable '$nugetSecret'."
throw "NuGet API key is not set. Set RepoUtilsSecrets slot '$nugetSecret' (nugetSecret)."
}
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {

View File

@ -6,8 +6,7 @@
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using npmSecret (logical secret name).
Pass the token via an environment variable named like the configured npm secret (e.g. Npm).
Publishes packages in configured order using RepoUtilsSecrets slot Npm.
Uses a temporary .npmrc in the workspace root.
#>
@ -85,6 +84,8 @@ function Invoke-Plugin {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
@ -99,14 +100,9 @@ function Invoke-Plugin {
return
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret'
if ([string]::IsNullOrWhiteSpace($npmSecret)) {
throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)."
}
$npmToken = Get-SecretEnvironmentValue -Name $npmSecret
$npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
if ([string]::IsNullOrWhiteSpace($npmToken)) {
throw "npm API key is not set. Set environment variable '$npmSecret'."
throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)."
}
$registryHost = ([uri]$registry).Host

View File

@ -101,7 +101,6 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret'
$configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
@ -112,6 +111,7 @@ function Invoke-Plugin {
$releaseAssetPaths = @()
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json."
@ -138,13 +138,9 @@ function Invoke-Plugin {
Assert-Command gh
if ([string]::IsNullOrWhiteSpace($githubSecret)) {
throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)."
}
$ghToken = Get-SecretEnvironmentValue -Name $githubSecret
$ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($ghToken)) {
throw "GitHub token is not set. Set environment variable '$githubSecret'."
throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)."
}
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
@ -235,7 +231,7 @@ function Invoke-Plugin {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
}
throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository."
throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository."
}
Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"