Compare commits

...

2 Commits

Author SHA1 Message Date
Yu Leng
eb8b016f28 CliShim: fix argv[0] parsing, dedupe publish, AOT cleanups, add tests
Addresses review feedback on the CLI shim launchers:

- Program.cs: extract argv[0] stripping to CommandLine.cs and skip leading
  whitespace first, so a command line padded by a non-shell launcher no longer
  leaks the program name into the forwarded arguments. Split exit codes
  (9009 = unknown command name, 1 = target missing / launch failed) so a caller
  can tell a typo'd command from a broken install.
- CliShim.csproj: drop the Common.SelfContained.props import (a plain solution
  build was copying ~77 MB of self-contained runtime into bin\ for an artifact
  nothing consumes); AOT gets its RID from the publish -r flag. Suppress the
  native sidecar PDB.
- tools/build/publish-cli-shims.ps1: new shared script used by both the CI step
  and build-installer.ps1. The Targets dictionary in Program.cs is the single
  source of truth for command names; the script validates that CliShims.wxs and
  ESRPSigning_core.json reference exactly that set (fails the build on drift),
  stages one exe per name, and removes the source launcher/PDB so the cli folder
  holds only what ships. Adds the VS Installer dir to PATH when vswhere is not
  discoverable so Native AOT linking works from a non-developer shell (the CI
  pwsh step), which otherwise fails at link with "'vswhere.exe' is not recognized".
- job-build-project.yml / build-installer.ps1: call the shared script.
- CliShims.wxs: note that already-open terminals must be reopened before the
  short command names resolve.
- Add CliShim.UnitTests (MSTest) covering the argv[0] parser including the
  leading-whitespace regression; register it in PowerToys.slnx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:13:06 +08:00
Yu Leng
dbb1f76906 Add CLI shim launchers for FancyZones/ImageResizer/FileLocksmith CLIs
Introduce a tiny Native AOT "multi-call" launcher (tools/CliShim) installed
into <install>\cli and added to PATH, so the existing CLIs are callable by
short command names from a terminal:

  fancyzones     -> FancyZonesCLI.exe
  imageresizer   -> WinUI3Apps\PowerToys.ImageResizerCLI.exe
  filelocksmith  -> FileLocksmithCLI.exe

One AOT binary is published and copied to one exe per command name; each
resolves its own file name to the target CLI, forwards arguments verbatim,
shares the console, and propagates the exit code.

- tools/CliShim: net10.0 Native AOT console project (dependency-free, ~1.3MB)
- PowerToys.slnx: register the project (x64/ARM64)
- installer (Product.wxs / CliShims.wxs / wixproj): install the 3 shims into
  the cli folder and append it to PATH (per-user/per-machine)
- build-installer.ps1 + .pipelines/v2 job-build-project.yml: AOT-publish and
  stage the shims before signing/packaging
- ESRPSigning_core.json: code-sign the 3 shim exes

Work in progress - not ready for use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:15:17 +08:00
13 changed files with 468 additions and 0 deletions

View File

@@ -64,6 +64,10 @@
"FancyZonesCLI.exe",
"FancyZonesCLI.dll",
"cli\\fancyzones.exe",
"cli\\imageresizer.exe",
"cli\\filelocksmith.exe",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",
"PowerToys.GcodePreviewHandlerCpp.dll",

View File

@@ -454,6 +454,18 @@ jobs:
msbuildArchitecture: x64
maximumCpuCount: true
# CLI shims: publish ONE Native AOT binary and stage one exe per command name into
# <BuildPlatform>\<BuildConfiguration>\cli so the files exist BEFORE the signing step
# (ESRPSigning_core.json) and the installer harvest. The shared script owns the command
# list (single source of truth) and the drift validation; it is the same code path the
# local installer build (tools/build/build-installer.ps1) uses.
- pwsh: |-
& "$(Build.SourcesDirectory)\tools\build\publish-cli-shims.ps1" `
-Platform '$(BuildPlatform)' `
-Configuration '$(BuildConfiguration)' `
-OutDir "$(Build.SourcesDirectory)\$(BuildPlatform)\$(BuildConfiguration)\cli"
displayName: Publish CLI shims (Native AOT)
### HACK: On ARM64 builds, building an app with Windows App SDK copies the x64 WebView2 dll instead of the ARM64 one. This task makes sure the right dll is used.
- task: CopyFiles@2
displayName: HACK Copy core WebView2 ARM64 dll to output directory

View File

@@ -1138,6 +1138,14 @@
</Project>
<Project Path="src/Update/PowerToys.Update.vcxproj" Id="44ce9ae1-4390-42c5-bacc-0fd6b40aa203" />
<Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" />
<Project Path="tools/CliShim/CliShim.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="tools/CliShim.UnitTests/CliShim.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Solution>

View File

@@ -0,0 +1,72 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<?include $(sys.CURRENTDIR)\Common.wxi?>
<!--
CLI shims: tiny Native AOT launchers installed into the "cli" subfolder. Each shim
forwards to a real PowerToys CLI (one level up, or under WinUI3Apps). The cli folder
is appended to PATH so the shims are callable by name from a terminal.
-->
<Fragment>
<DirectoryRef Id="CliFolder">
<Component Id="CliShim_fancyzones_exe" Guid="63800E2A-21B9-4A92-AD25-9C1DC7593F4F" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="CliShim_fancyzones_exe" Value="" KeyPath="yes" />
</RegistryKey>
<File Source="$(var.BinDir)cli\fancyzones.exe" Id="CliShim_fancyzones.exe" Checksum="yes" />
</Component>
<Component Id="CliShim_imageresizer_exe" Guid="2BC274AA-FECD-4A85-BF70-06670A6B99A5" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="CliShim_imageresizer_exe" Value="" KeyPath="yes" />
</RegistryKey>
<File Source="$(var.BinDir)cli\imageresizer.exe" Id="CliShim_imageresizer.exe" Checksum="yes" />
</Component>
<Component Id="CliShim_filelocksmith_exe" Guid="2DD830DD-78D4-46EE-B23B-3EAB3D838EDF" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="CliShim_filelocksmith_exe" Value="" KeyPath="yes" />
</RegistryKey>
<File Source="$(var.BinDir)cli\filelocksmith.exe" Id="CliShim_filelocksmith.exe" Checksum="yes" />
</Component>
<!--
Append the cli folder to PATH (per-user vs per-machine, mirroring Core.wxs).
MSI broadcasts WM_SETTINGCHANGE on commit, so Explorer and newly-opened terminals
pick up the new PATH; terminals already open at install time must be reopened before
the short command names (fancyzones / imageresizer / filelocksmith) resolve.
-->
<?if $(var.PerUser) = "true" ?>
<Component Id="cli_env_path_user" Guid="87AA86E8-81E8-44CB-9519-FC70CF7BEA56" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="cli_env_path_user" Value="" KeyPath="yes" />
</RegistryKey>
<Environment Id="AddCliToUserPath" Name="PATH" Action="set" Part="last" System="no" Value="[CliFolder]" />
</Component>
<?else?>
<Component Id="cli_env_path_machine" Guid="739F3916-2247-4F1B-8BBD-0949A5B870F7" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="cli_env_path_machine" Value="" KeyPath="yes" />
</RegistryKey>
<Environment Id="AddCliToMachinePath" Name="PATH" Action="set" Part="last" System="yes" Value="[CliFolder]" />
</Component>
<?endif?>
</DirectoryRef>
<ComponentGroup Id="CliShimsComponentGroup">
<Component Id="RemoveCliFolder" Guid="831C56D8-9FEB-41B2-8A8C-BD49487479BB" Directory="CliFolder">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="RemoveCliFolder" Value="" KeyPath="yes" />
</RegistryKey>
<RemoveFolder Id="RemoveFolderCliFolder" Directory="CliFolder" On="uninstall" />
</Component>
<ComponentRef Id="CliShim_fancyzones_exe" />
<ComponentRef Id="CliShim_imageresizer_exe" />
<ComponentRef Id="CliShim_filelocksmith_exe" />
<?if $(var.PerUser) = "true" ?>
<ComponentRef Id="cli_env_path_user" />
<?else?>
<ComponentRef Id="cli_env_path_machine" />
<?endif?>
</ComponentGroup>
</Fragment>
</Wix>

View File

@@ -131,6 +131,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="Settings.wxs" />
<Compile Include="ShortcutGuide.wxs" />
<Compile Include="Tools.wxs" />
<Compile Include="CliShims.wxs" />
<Compile Include="MouseWithoutBorders.wxs" />
<Compile Include="WinUI3Applications.wxs" />
<Compile Include="MonacoSRC.wxs" />

View File

@@ -67,6 +67,7 @@
<ComponentGroupRef Id="DscResourcesComponentGroup" />
<ComponentGroupRef Id="WindowsAppSDKComponentGroup" />
<ComponentGroupRef Id="ToolComponentGroup" />
<ComponentGroupRef Id="CliShimsComponentGroup" />
<ComponentGroupRef Id="MonacoSRCHeatGenerated" />
<ComponentGroupRef Id="WorkspacesComponentGroup" />
<ComponentGroupRef Id="CmdPalComponentGroup" />
@@ -299,6 +300,7 @@
</Directory>
</Directory>
<Directory Id="ToolsFolder" Name="Tools" />
<Directory Id="CliFolder" Name="cli" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Plain net10.0: the code under test (CommandLine) uses only BCL string handling. -->
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerToys.CliShim.UnitTests</RootNamespace>
<AssemblyName>PowerToys.CliShim.UnitTests</AssemblyName>
<Platforms>x64;ARM64</Platforms>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- Link the parser source directly (instead of a ProjectReference to the AOT exe) so the
tests run on the normal runtime, free of RID/AOT/Platform entanglement. -->
<Compile Include="..\CliShim\CommandLine.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerToys.CliShim.UnitTests;
[TestClass]
public sealed class CommandLineTests
{
[DataTestMethod]
// Normal shell launches: unquoted program name, ends at first whitespace.
[DataRow("fancyzones arg", "arg")]
[DataRow("fancyzones a b c", "a b c")]
[DataRow("fancyzones", "")]
[DataRow("filelocksmith", "")]
// Quoted program name (path with spaces): ends at the closing quote, no backslash-escaping.
[DataRow(@"""C:\Program Files\PowerToys\cli\fancyzones.exe"" arg", "arg")]
[DataRow(@"""C:\Program Files\PowerToys\cli\fancyzones.exe""", "")]
// The user's exact quoting in the tail is preserved verbatim (the whole point of the shim).
[DataRow(@"""C:\cli\fancyzones.exe"" ""a b""", @"""a b""")]
[DataRow(@"fancyzones --path ""C:\a b\c.png""", @"--path ""C:\a b\c.png""")]
// Tabs count as whitespace for both the argv[0] terminator and the trim.
[DataRow("fancyzones\targ", "arg")]
[DataRow("fancyzones \t arg", "arg")]
// Regression: a command line padded with leading whitespace must NOT leak the program name
// (a non-shell parent can pass this via CreateProcessW; the OS loader never does).
[DataRow(" fancyzones arg", "arg")]
[DataRow(" fancyzones", "")]
[DataRow(@" ""C:\cli\fancyzones.exe"" arg", "arg")]
// Degenerate inputs.
[DataRow("", "")]
// Unterminated argv[0] quote: ends at end-of-string (CRT-faithful), so no arguments remain.
[DataRow(@"""C:\Program Files\app", "")]
public void StripArgumentZero_ReturnsForwardedTail(string commandLine, string expected)
{
Assert.AreEqual(expected, CommandLine.StripArgumentZero(commandLine));
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings (analyzers, RepoRoot, Platforms, versioning) come from the root Directory.Build.props. -->
<!--
No Common.SelfContained.props import: that sets SelfContained=true, which would make a
plain (non-publish) solution build copy the whole .NET runtime (~77 MB) into bin\ for an
artifact nothing consumes. Native AOT only needs a RuntimeIdentifier, which the publish
step supplies via "-r win-<arch>" (and PublishAot implies self-contained on publish).
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Plain net10.0: this shim only uses BCL types (Process/Environment/Console), so it needs no Windows/WinRT projection. -->
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerToys.CliShim</RootNamespace>
<AssemblyName>PowerToys.CliShim</AssemblyName>
<Platforms>x64;ARM64</Platforms>
<Nullable>enable</Nullable>
<!-- Native AOT: a tiny, dependency-free single exe (no .NET runtime shipped alongside). -->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StackTraceSupport>false</StackTraceSupport>
<OptimizationPreference>Size</OptimizationPreference>
<!-- Don't emit the native sidecar .pdb; it is never harvested/signed and only clutters the staging dir. -->
<NativeDebugSymbols>false</NativeDebugSymbols>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,64 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerToys.CliShim;
/// <summary>
/// Helpers for working with the raw process command line. Kept in its own internal type
/// (rather than inside <see cref="Program"/>) so the parsing logic can be unit tested by
/// linking this single source file into the test project.
/// </summary>
internal static class CommandLine
{
/// <summary>
/// Returns the command line with its first token (argv[0]) removed, following the C
/// runtime rule for the program name: leading whitespace is skipped first; then, when
/// argv[0] starts with a quote it ends at the next quote (no backslash-escaping for the
/// program name), otherwise it ends at the first whitespace. Whitespace before the first
/// real argument is then trimmed.
/// </summary>
/// <param name="commandLine">The raw process command line (for example <see cref="System.Environment.CommandLine"/>).</param>
/// <returns>The remaining arguments, verbatim.</returns>
internal static string StripArgumentZero(string commandLine)
{
int index = 0;
// Skip leading whitespace before argv[0]. The OS loader never produces this, but a
// non-shell parent that calls CreateProcessW with a padded lpCommandLine can, and
// without this the unquoted scan below would stall at index 0 and leak the program
// name into the forwarded arguments.
while (index < commandLine.Length && (commandLine[index] == ' ' || commandLine[index] == '\t'))
{
index++;
}
if (index < commandLine.Length && commandLine[index] == '"')
{
index++;
while (index < commandLine.Length && commandLine[index] != '"')
{
index++;
}
if (index < commandLine.Length)
{
index++; // Consume the closing quote.
}
}
else
{
while (index < commandLine.Length && commandLine[index] != ' ' && commandLine[index] != '\t')
{
index++;
}
}
while (index < commandLine.Length && (commandLine[index] == ' ' || commandLine[index] == '\t'))
{
index++;
}
return commandLine[index..];
}
}

98
tools/CliShim/Program.cs Normal file
View File

@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace PowerToys.CliShim;
/// <summary>
/// A tiny multi-call launcher ("shim"). One Native AOT binary is copied to several
/// command names (for example fancyzones.exe). At run time it resolves its own file
/// name to the matching PowerToys CLI, forwards the user's arguments verbatim, shares
/// the console, and returns the launched process's exit code unchanged.
/// </summary>
internal static class Program
{
// The exit code cmd.exe returns for "command not found". Used only when the shim is
// invoked under a name that is not in the Targets table (for example a renamed copy).
private const int ExitCommandNotMapped = 9009;
// A distinct code for "the command is known, but its target could not be launched"
// (missing target, Process.Start failure). Keeping this separate from 9009 lets a
// calling script tell a typo'd/unmapped command from a broken install.
private const int ExitLaunchFailed = 1;
// Command name (the shim's own file name without extension) -> target CLI path,
// relative to the shim's own directory. The shims are installed in "<install>\cli\",
// so the targets sit one level up (install root) or under the WinUI3Apps subfolder.
//
// This dictionary is the single source of truth for the command names: tools/build/
// publish-cli-shims.ps1 parses the keys below to decide which exe copies to stage, and
// validates that CliShims.wxs and ESRPSigning_core.json reference exactly the same set.
private static readonly Dictionary<string, string> Targets = new(StringComparer.OrdinalIgnoreCase)
{
["fancyzones"] = @"..\FancyZonesCLI.exe",
["imageresizer"] = @"..\WinUI3Apps\PowerToys.ImageResizerCLI.exe",
["filelocksmith"] = @"..\FileLocksmithCLI.exe",
};
private static int Main()
{
// Stay alive on Ctrl+C / Ctrl+Break so we can still capture the child's exit
// code; the child shares the console group and receives the signal directly.
Console.CancelKeyPress += static (_, e) => e.Cancel = true;
string shimPath = Environment.ProcessPath ?? string.Empty;
string shimDirectory = Path.GetDirectoryName(shimPath) ?? Directory.GetCurrentDirectory();
string commandName = Path.GetFileNameWithoutExtension(shimPath);
if (!Targets.TryGetValue(commandName, out string? relativeTarget))
{
Console.Error.WriteLine($"cli-shim: no PowerToys CLI is mapped to the command '{commandName}'.");
Console.Error.WriteLine($"cli-shim: known commands: {string.Join(", ", Targets.Keys)}.");
return ExitCommandNotMapped;
}
string targetPath = Path.GetFullPath(Path.Combine(shimDirectory, relativeTarget));
if (!File.Exists(targetPath))
{
Console.Error.WriteLine($"cli-shim: target not found: \"{targetPath}\".");
return ExitLaunchFailed;
}
// Forward the user's arguments byte-for-byte. Environment.CommandLine is the raw
// command line (the managed equivalent of GetCommandLineW); stripping argv[0]
// preserves the user's exact quoting, which re-quoting parsed args would corrupt.
string forwardedArguments = CommandLine.StripArgumentZero(Environment.CommandLine);
ProcessStartInfo startInfo = new()
{
FileName = targetPath,
Arguments = forwardedArguments,
UseShellExecute = false, // Inherit stdin/stdout/stderr and stay in this console.
};
try
{
using Process? child = Process.Start(startInfo);
if (child is null)
{
Console.Error.WriteLine($"cli-shim: failed to start \"{targetPath}\".");
return ExitLaunchFailed;
}
child.WaitForExit();
return child.ExitCode;
}
catch (Exception ex)
{
Console.Error.WriteLine($"cli-shim: failed to launch \"{targetPath}\": {ex.Message}");
return ExitLaunchFailed;
}
}
}

View File

@@ -381,6 +381,12 @@ try {
if (-not $SkipBuild) {
RestoreThenBuild 'tools\BugReportTool\BugReportTool.sln' $commonArgs $Platform $Configuration
RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration
# CLI shims: publish ONE Native AOT binary and stage one exe per command name into
# <Platform>\<Configuration>\cli for the installer to harvest. The shared script owns
# the single-source-of-truth command list and the drift validation; AOT linking relies
# on the VC toolchain already set up by the installer build environment.
& (Join-Path $repoRoot 'tools\build\publish-cli-shims.ps1') -Platform $Platform -Configuration $Configuration -OutDir (Join-Path $buildOutputPath 'cli')
}
# Set NUGET_PACKAGES environment variable if not set, to help wixproj find heat.exe

View File

@@ -0,0 +1,98 @@
# Copyright (c) Microsoft Corporation
# The Microsoft Corporation licenses this file to you under the MIT license.
# See the LICENSE file in the project root for more information.
<#
.SYNOPSIS
Publishes the Native AOT CLI shim and stages one exe per command name.
.DESCRIPTION
Publishes tools/CliShim as a single Native AOT binary, then copies it to one exe per
command name into -OutDir (the installer's "cli" staging folder), and removes the source
launcher so the folder holds exactly what the installer harvests and ESRP signs.
Shared by both entry points so the logic lives in one place:
- the local installer build (tools/build/build-installer.ps1)
- the CI build (.pipelines/v2/templates/job-build-project.yml)
The command names come from a single source of truth: the keys of the Targets dictionary
in tools/CliShim/Program.cs. This script also verifies that CliShims.wxs and
ESRPSigning_core.json reference exactly that set, failing the build on any drift (which
would otherwise ship a shim that always exits 9009, or silently omit one).
.NOTES
Native AOT linking needs the Desktop C++ workload; the .NET ILCompiler targets locate it
automatically via findvcvarsall.bat (no separate vcvars activation required).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string] $Platform, # x64 | ARM64 (any case)
[Parameter(Mandatory = $true)][string] $Configuration, # Debug | Release
[Parameter(Mandatory = $true)][string] $OutDir # staging "cli" folder
)
$ErrorActionPreference = 'Stop'
# tools/build/<this>.ps1 -> repo root is two levels up.
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$shimProj = Join-Path $repoRoot 'tools\CliShim\CliShim.csproj'
$programCs = Join-Path $repoRoot 'tools\CliShim\Program.cs'
$shimsWxs = Join-Path $repoRoot 'installer\PowerToysSetupVNext\CliShims.wxs'
$esrpJson = Join-Path $repoRoot '.pipelines\ESRPSigning_core.json'
function Get-CapturedValues([string] $Path, [string] $Pattern) {
if (-not (Test-Path $Path)) { throw "publish-cli-shims: file not found: $Path" }
$found = Select-String -Path $Path -Pattern $Pattern -AllMatches
if (-not $found) { return @() }
@($found.Matches | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
}
# Single source of truth: the command names are the keys of the Targets dictionary in Program.cs.
$commandNames = Get-CapturedValues $programCs '\["([^"]+)"\]\s*=\s*@"'
if (-not $commandNames) { throw "publish-cli-shims: could not parse any command names from $programCs" }
# Drift guard: the installer harvest and the signing list must reference exactly these names.
$consumers = @(
@{ Name = 'CliShims.wxs'; Actual = (Get-CapturedValues $shimsWxs 'cli\\([^"\\]+)\.exe') }
@{ Name = 'ESRPSigning_core.json'; Actual = (Get-CapturedValues $esrpJson 'cli\\\\([^"\\]+)\.exe') }
)
foreach ($consumer in $consumers) {
if (Compare-Object -ReferenceObject $commandNames -DifferenceObject $consumer.Actual) {
throw ("publish-cli-shims: command set mismatch. Program.cs has [$($commandNames -join ', ')] " +
"but $($consumer.Name) has [$($consumer.Actual -join ', ')]. " +
'Add/rename the shim in Program.cs, CliShims.wxs, and ESRPSigning_core.json together.')
}
}
# Native AOT linking shells out to vswhere.exe to locate the MSVC toolchain (which then sets
# LIB/INCLUDE via vcvars). In a plain, non-developer shell -- e.g. the CI 'pwsh' step, which
# unlike the local installer build does not activate a VS Dev environment -- vswhere may not be
# on PATH, and the link step fails with "'vswhere.exe' is not recognized". Add the fixed VS
# Installer location so the publish works without requiring callers to pre-activate vcvars.
if (-not (Get-Command 'vswhere.exe' -ErrorAction SilentlyContinue)) {
$vsInstaller = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer'
if (Test-Path (Join-Path $vsInstaller 'vswhere.exe')) {
$env:PATH = "$vsInstaller;$env:PATH"
Write-Host "[CLI-SHIM] Added VS Installer to PATH so AOT linking can find vswhere.exe."
}
}
# RID from platform (project config is x64;ARM64).
$cliPlatform = if ($Platform -ieq 'arm64') { 'ARM64' } else { 'x64' }
$rid = "win-$($cliPlatform.ToLower())"
Write-Host "[CLI-SHIM] Publishing Native AOT shim ($rid) -> $OutDir"
dotnet publish $shimProj -c $Configuration -r $rid -p:Platform=$cliPlatform -o $OutDir --nologo
if ($LASTEXITCODE -ne 0) { throw "publish-cli-shims: dotnet publish failed with exit code $LASTEXITCODE" }
# Copy the single published binary to one exe per command name, then drop the source launcher
# (and any sidecar pdb) so the staging dir holds exactly what the installer harvests and signs.
$srcExe = Join-Path $OutDir 'PowerToys.CliShim.exe'
if (-not (Test-Path $srcExe)) { throw "publish-cli-shims: expected '$srcExe' was not produced by dotnet publish." }
foreach ($name in $commandNames) {
Copy-Item $srcExe (Join-Path $OutDir "$name.exe") -Force
}
Remove-Item $srcExe -Force
Remove-Item (Join-Path $OutDir 'PowerToys.CliShim.pdb') -Force -ErrorAction SilentlyContinue
Write-Host "[CLI-SHIM] Staged: $($commandNames -join ', ')"