Add native CLI shims for PowerToys command-line tools (#48631)

## Summary of the Pull Request

Adds a native C++ multi-call shim that exposes existing PowerToys CLIs
through `PATH`. The shims are installed under the PowerToys `bin`
subfolder and follow the `PowerToys.<ModuleName>.CLI.exe` naming
convention. The launcher preserves the raw argument tail, shares the
caller's console, and returns the target process exit code.

| PATH-visible command | Target executable |
| --- | --- |
| `PowerToys.FancyZones.CLI.exe` | `FancyZonesCLI.exe` |
| `PowerToys.ImageResizer.CLI.exe` |
`WinUI3Apps/PowerToys.ImageResizerCLI.exe` |
| `PowerToys.FileLocksmith.CLI.exe` | `FileLocksmithCLI.exe` |
| `PowerToys.PowerDisplay.CLI.exe` |
`WinUI3Apps/PowerToys.PowerDisplay.Cli.exe` |

proof of this work:
<img width="1044" height="294" alt="image"
src="https://github.com/user-attachments/assets/b659c552-5c08-4430-85c3-eba48f286eb0"
/>
<img width="1137" height="244" alt="image"
src="https://github.com/user-attachments/assets/5fed493f-dc30-428d-a618-bf612ccf3635"
/>

<img width="1727" height="868" alt="image"
src="https://github.com/user-attachments/assets/5c32fd2e-4a3a-4138-b968-fb5434eebec3"
/>


## PR Checklist

- [x] Closes: #48634
- [x] **Communication:** Discussed with core contributors in this PR
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** CLI diagnostic messages are not localized
- [x] **Dev docs:** Updated CLI naming and installation conventions
- [x] **New binaries:** Added on the required places
  - [x] Signing JSON
  - [x] WiX installer entries
- [x] CI builds through `PowerToys.slnx`; no dedicated YML step is
required
  - [x] The existing release pipeline covers the solution and installer
- [x] **Documentation updated:** `doc/devdocs/cli-conventions.md`

## Detailed Description of the Pull Request / Additional comments

- Uses one native launcher binary for all commands and resolves the
target from the invoked shim filename.
- Installs PATH-visible shims under `PowerToys\bin`.
- Keeps the existing module CLI binaries and their deployment locations
unchanged.
- Rejects the previous unsuffixed and `*cli` command aliases.

## Validation Steps Performed

- Built `tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj` in
`Release|x64`: 0 warnings, 0 errors.
- Ran `CliShim.UnitTests.dll` with `vstest.console.exe`: 5/5 tests
passed.
- Verified the CLI manifest, WiX command names, and `bin` installation
directory are synchronized.
- Ran `git diff --check`.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d11c4221-248f-44a9-85fb-7017ed43f4ce
This commit is contained in:
moooyo
2026-08-14 14:32:27 +08:00
committed by GitHub
parent becc96f59c
commit ae416c045a
18 changed files with 1340 additions and 2 deletions

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Globals">
<ProjectGuid>{D4B0ED68-867D-46B4-A03F-EC52B9FBEBE5}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>PowerToysCliShimUnitTests</RootNamespace>
<ProjectName>CliShim.UnitTests</ProjectName>
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\CliShim\</OutDir>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<!-- Supplies the shared command mapping and generates CliShimTargets.g.inc into $(IntDir). -->
<Import Project="..\CliShim\CliShimManifest.props" />
<PropertyGroup>
<VcpkgEnabled>false</VcpkgEnabled>
<VcpkgManifestEnabled>false</VcpkgManifestEnabled>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(IntDir);$(MSBuildProjectDirectory)\..\CliShim;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<!-- Compile the parser directly; the project reference supplies the integration-test executable. -->
<ClCompile Include="..\CliShim\CommandLine.cpp" />
<ClCompile Include="CommandLineTests.cpp" />
<ClCompile Include="LauncherIntegrationTests.cpp" />
<ClInclude Include="..\CliShim\CommandLine.h" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliShim\CliShim.vcxproj">
<Project>{8A7FB7FA-65EA-4004-BA73-1B237435A57B}</Project>
<LinkLibraryDependencies>false</LinkLibraryDependencies>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<Target Name="CopyCliShimUnderTest"
AfterTargets="Build"
Condition="'$(_IsSkippedTestProject)' != 'true'">
<PropertyGroup>
<_CliShimUnderTest>$(RepoRoot)$(Platform)\$(Configuration)\CliShim\PowerToys.CliShim.exe</_CliShimUnderTest>
</PropertyGroup>
<Error Condition="!Exists('$(_CliShimUnderTest)')"
Text="CLI shim under test was not built: '$(_CliShimUnderTest)'." />
<Copy SourceFiles="$(_CliShimUnderTest)"
DestinationFolder="$(OutDir)"
SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,73 @@
// 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.
#include <string>
#include <CppUnitTest.h>
#include "CommandLine.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
namespace CliShimUnitTests
{
TEST_CLASS(CommandLineTests)
{
public:
TEST_METHOD(StripArgumentZero_ReturnsForwardedTail)
{
struct Case
{
const wchar_t* commandLine;
const wchar_t* expected;
};
const Case cases[] = {
{ L"PowerToys.FancyZones.CLI arg", L"arg" },
{ L"PowerToys.FancyZones.CLI a b c", L"a b c" },
{ L"PowerToys.FancyZones.CLI", L"" },
{ L"PowerToys.FileLocksmith.CLI", L"" },
{ LR"("C:\Program Files\PowerToys\bin\PowerToys.FancyZones.CLI.exe" arg)", L"arg" },
{ LR"("C:\Program Files\PowerToys\bin\PowerToys.FancyZones.CLI.exe")", L"" },
// Quotes only toggle, so a quoted argv[0] does not end at the closing quote: an
// argument glued to it is part of the program name. CommandLineToArgvW would
// forward `--help` here; the CRT - and so every target CLI - sees no arguments.
{ LR"("C:\bin\PowerToys.FancyZones.CLI.exe"--help)", L"" },
// Partially quoted program names - the batch idiom of quoting only the variable,
// `"%ProgramFiles%"\PowerToys\bin\...`, which cmd.exe passes through verbatim - are
// resolved exactly as the CRT resolves them, so the target sees the same tail it
// would have seen had the caller invoked it directly rather than through the shim.
{ LR"("C:\Program Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg)", L"arg" },
{ LR"(C:\Program" Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg)", L"arg" },
{ LR"("C:\Program Files"\PowerToys\bin\PowerToys.FancyZones.CLI.exe)", L"" },
{ LR"("C:\bin\PowerToys.FancyZones.CLI.exe" "a b")", LR"("a b")" },
{ LR"(PowerToys.FancyZones.CLI --path "C:\a b\c.png")", LR"(--path "C:\a b\c.png")" },
{ L"PowerToys.FancyZones.CLI\targ", L"arg" },
{ L"PowerToys.FancyZones.CLI \t arg", L"arg" },
// Non-shell CreateProcessW callers can prepend whitespace; argv[0] must not leak.
{ L" PowerToys.FancyZones.CLI arg", L"arg" },
{ L" PowerToys.FancyZones.CLI", L"" },
{ LR"( "C:\bin\PowerToys.FancyZones.CLI.exe" arg)", L"arg" },
{ L"", L"" },
// An unterminated argv[0] quote consumes the remaining command line.
{ LR"("C:\Program Files\app)", L"" },
};
for (const Case& testCase : cases)
{
const std::wstring actual = CommandLine::StripArgumentZero(testCase.commandLine);
const std::wstring message = std::wstring(L"input: <") + testCase.commandLine + L">";
Assert::AreEqual(std::wstring(testCase.expected), actual, message.c_str());
}
}
};
}

View File

@@ -0,0 +1,631 @@
// 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.
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <TlHelp32.h>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <CppUnitTest.h>
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
extern "C" IMAGE_DOS_HEADER __ImageBase;
namespace
{
constexpr DWORD ExitCommandNotMapped = 9009;
constexpr DWORD ExitTargetNotFound = 9010;
constexpr DWORD ForwardedExitCode = 37;
// How often the process-tree helpers re-scan while waiting for a process to appear.
constexpr DWORD ProcessPollIntervalMilliseconds = 50;
// How many times a temporary directory removal is retried before it is given up on.
constexpr int CleanupAttempts = 20;
// Only used to unblock the test run if the shim ever hangs; the wait result is asserted.
constexpr DWORD TimeoutTerminationExitCode = 0xFFFFFFFF;
struct ShimMapping
{
const wchar_t* command;
const wchar_t* relativeTarget;
};
// Generated from CliShimManifest.props, the same table the shim itself is built from, so a
// newly mapped command is covered here automatically instead of by hand.
constexpr ShimMapping ExpectedMappings[] = {
#include "CliShimTargets.g.inc"
};
constexpr const wchar_t* RejectedLegacyCommands[] = {
L"fancyzones",
L"imageresizer",
L"filelocksmith",
L"powerdisplay",
L"fancyzonescli",
L"imageresizercli",
L"filelocksmithcli",
L"powerdisplaycli",
};
class UniqueHandle
{
public:
UniqueHandle() = default;
explicit UniqueHandle(HANDLE value) noexcept :
handle{ value }
{
}
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
UniqueHandle(UniqueHandle&& other) noexcept :
handle{ std::exchange(other.handle, nullptr) }
{
}
UniqueHandle& operator=(UniqueHandle&& other) noexcept
{
if (this != &other)
{
Reset();
handle = std::exchange(other.handle, nullptr);
}
return *this;
}
~UniqueHandle()
{
Reset();
}
void Reset() noexcept
{
if (handle != nullptr && handle != INVALID_HANDLE_VALUE)
{
CloseHandle(handle);
}
handle = nullptr;
}
HANDLE Get() const noexcept
{
return handle;
}
private:
HANDLE handle = nullptr;
};
std::filesystem::path GetTestBinaryDirectory()
{
wchar_t modulePath[MAX_PATH]{};
const DWORD length = GetModuleFileNameW(
reinterpret_cast<HMODULE>(&__ImageBase),
modulePath,
ARRAYSIZE(modulePath));
Assert::IsTrue(length > 0 && length < ARRAYSIZE(modulePath), L"Could not locate the test module.");
return std::filesystem::path{ modulePath }.parent_path();
}
std::filesystem::path GetShimUnderTest()
{
return GetTestBinaryDirectory() / L"PowerToys.CliShim.exe";
}
std::filesystem::path GetSystemDirectoryPath()
{
wchar_t systemDirectory[MAX_PATH]{};
const UINT length = GetSystemDirectoryW(systemDirectory, ARRAYSIZE(systemDirectory));
Assert::IsTrue(length > 0 && length < ARRAYSIZE(systemDirectory), L"Could not locate the system directory.");
return systemDirectory;
}
std::filesystem::path GetSystemCommandInterpreter()
{
return GetSystemDirectoryPath() / L"cmd.exe";
}
std::filesystem::path CreateTemporaryDirectory()
{
wchar_t temporaryRoot[MAX_PATH]{};
const DWORD rootLength = GetTempPathW(ARRAYSIZE(temporaryRoot), temporaryRoot);
Assert::IsTrue(rootLength > 0 && rootLength < ARRAYSIZE(temporaryRoot), L"Could not locate the temporary directory.");
wchar_t temporaryFile[MAX_PATH]{};
Assert::IsTrue(
GetTempFileNameW(temporaryRoot, L"PTS", 0, temporaryFile) != 0,
L"Could not reserve a temporary path.");
Assert::IsTrue(DeleteFileW(temporaryFile), L"Could not remove the temporary placeholder file.");
Assert::IsTrue(CreateDirectoryW(temporaryFile, nullptr), L"Could not create the temporary directory.");
return temporaryFile;
}
class TemporaryDirectory
{
public:
TemporaryDirectory() :
path{ CreateTemporaryDirectory() }
{
}
~TemporaryDirectory()
{
// A process that has just been terminated can still have its image mapped for a moment,
// which keeps the copied executable open, so give the removal a few attempts.
for (int attempt = 0; attempt < CleanupAttempts; ++attempt)
{
std::error_code error;
std::filesystem::remove_all(path, error);
if (!error)
{
return;
}
Sleep(ProcessPollIntervalMilliseconds);
}
}
const std::filesystem::path& GetPath() const noexcept
{
return path;
}
private:
std::filesystem::path path;
};
void CopyExecutable(const std::filesystem::path& source, const std::filesystem::path& destination)
{
std::error_code error;
std::filesystem::create_directories(destination.parent_path(), error);
Assert::AreEqual(0, error.value(), L"Could not create the destination directory.");
std::filesystem::copy_file(
source,
destination,
std::filesystem::copy_options::overwrite_existing,
error);
Assert::AreEqual(0, error.value(), L"Could not copy the executable.");
}
UniqueHandle OpenNul(const DWORD access)
{
SECURITY_ATTRIBUTES attributes{};
attributes.nLength = sizeof(attributes);
attributes.bInheritHandle = TRUE;
UniqueHandle handle{ CreateFileW(
L"NUL",
access,
FILE_SHARE_READ | FILE_SHARE_WRITE,
&attributes,
OPEN_EXISTING,
0,
nullptr) };
Assert::IsTrue(handle.Get() != INVALID_HANDLE_VALUE, L"Could not open NUL.");
return handle;
}
// Toolhelp is the whole story for observing the shim's process tree. A child has to be
// selected by image name rather than by "the first child", because the shim is also given a
// console host child; and a process that outlived its parent has to be found by image name
// alone, because by then its parent process id refers to a process that no longer exists.
UniqueHandle OpenProcessByImageName(
const std::wstring& imageName,
const std::optional<DWORD> parentProcessId,
const DWORD access)
{
UniqueHandle snapshot{ CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if (snapshot.Get() == INVALID_HANDLE_VALUE)
{
return {};
}
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
for (BOOL found = Process32FirstW(snapshot.Get(), &entry); found; found = Process32NextW(snapshot.Get(), &entry))
{
if (parentProcessId.has_value() && entry.th32ParentProcessID != *parentProcessId)
{
continue;
}
if (CompareStringOrdinal(entry.szExeFile, -1, imageName.c_str(), -1, TRUE) != CSTR_EQUAL)
{
continue;
}
UniqueHandle process{ OpenProcess(access, FALSE, entry.th32ProcessID) };
if (process.Get() != nullptr)
{
return process;
}
}
return {};
}
UniqueHandle WaitForProcessByImageName(
const std::wstring& imageName,
const std::optional<DWORD> parentProcessId,
const DWORD access,
const DWORD timeoutMilliseconds)
{
for (DWORD elapsed = 0;; elapsed += ProcessPollIntervalMilliseconds)
{
UniqueHandle process = OpenProcessByImageName(imageName, parentProcessId, access);
if (process.Get() != nullptr || elapsed >= timeoutMilliseconds)
{
return process;
}
Sleep(ProcessPollIntervalMilliseconds);
}
}
struct LaunchedProcess
{
UniqueHandle process;
DWORD processId = 0;
};
// CppUnitTest assertions throw, so anything the job-object tests start has to be torn down by
// a destructor rather than by a line at the end of the test: both tests deliberately start a
// process that never exits on its own, and a failing assertion would otherwise leave it
// running - and its executable locked - for as long as the agent lives.
class ProcessKiller
{
public:
explicit ProcessKiller(UniqueHandle process) noexcept :
handle{ std::move(process) }
{
}
ProcessKiller(const ProcessKiller&) = delete;
ProcessKiller& operator=(const ProcessKiller&) = delete;
~ProcessKiller()
{
if (handle.Get() != nullptr)
{
TerminateProcess(handle.Get(), 0);
WaitForSingleObject(handle.Get(), 5'000);
}
}
HANDLE Get() const noexcept
{
return handle.Get();
}
private:
UniqueHandle handle;
};
// Starts the executable without waiting for it. When either standard handle is supplied the
// child is started with inherited handles so the shim's own redirection behaviour is
// exercised; the handles left null keep whatever the test host is using.
LaunchedProcess StartProcess(
const std::filesystem::path& executable,
const std::wstring& arguments,
HANDLE standardInput,
HANDLE standardOutput)
{
std::wstring commandLine = L"\"" + executable.wstring() + L"\"";
if (!arguments.empty())
{
commandLine.push_back(L' ');
commandLine.append(arguments);
}
const bool redirect = standardInput != nullptr || standardOutput != nullptr;
STARTUPINFOW startupInfo{};
startupInfo.cb = sizeof(startupInfo);
if (redirect)
{
startupInfo.dwFlags = STARTF_USESTDHANDLES;
startupInfo.hStdInput = standardInput != nullptr ? standardInput : GetStdHandle(STD_INPUT_HANDLE);
startupInfo.hStdOutput = standardOutput != nullptr ? standardOutput : GetStdHandle(STD_OUTPUT_HANDLE);
startupInfo.hStdError = standardOutput != nullptr ? standardOutput : GetStdHandle(STD_ERROR_HANDLE);
}
PROCESS_INFORMATION processInfo{};
if (!CreateProcessW(
executable.c_str(),
commandLine.data(),
nullptr,
nullptr,
redirect ? TRUE : FALSE,
CREATE_NO_WINDOW,
nullptr,
nullptr,
&startupInfo,
&processInfo))
{
const std::wstring message = L"CreateProcessW failed with error " + std::to_wstring(GetLastError()) + L".";
Assert::Fail(message.c_str());
}
CloseHandle(processInfo.hThread);
return { UniqueHandle{ processInfo.hProcess }, processInfo.dwProcessId };
}
// Runs the executable to completion and returns its exit code.
DWORD RunProcess(
const std::filesystem::path& executable,
const std::wstring& arguments,
HANDLE standardInput,
HANDLE standardOutput)
{
const LaunchedProcess launched = StartProcess(executable, arguments, standardInput, standardOutput);
const DWORD waitResult = WaitForSingleObject(launched.process.Get(), 30'000);
if (waitResult != WAIT_OBJECT_0)
{
TerminateProcess(launched.process.Get(), TimeoutTerminationExitCode);
WaitForSingleObject(launched.process.Get(), 5'000);
}
DWORD exitCode = 0;
const BOOL gotExitCode = GetExitCodeProcess(launched.process.Get(), &exitCode);
Assert::AreEqual(static_cast<DWORD>(WAIT_OBJECT_0), waitResult, L"The shim process did not exit.");
Assert::IsTrue(gotExitCode, L"Could not read the shim process exit code.");
return exitCode;
}
DWORD RunAndGetExitCode(const std::filesystem::path& executable, const std::wstring& arguments = {})
{
return RunProcess(executable, arguments, nullptr, nullptr);
}
std::string RunAndCaptureStandardOutput(
const std::filesystem::path& executable,
const std::wstring& arguments,
const std::filesystem::path& capturePath,
DWORD& exitCode)
{
SECURITY_ATTRIBUTES attributes{};
attributes.nLength = sizeof(attributes);
attributes.bInheritHandle = TRUE;
UniqueHandle output{ CreateFileW(
capturePath.c_str(),
GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
&attributes,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
nullptr) };
Assert::IsTrue(output.Get() != INVALID_HANDLE_VALUE, L"Could not create the output capture file.");
const UniqueHandle input = OpenNul(GENERIC_READ);
exitCode = RunProcess(executable, arguments, input.Get(), output.Get());
output.Reset();
std::ifstream stream{ capturePath, std::ios::binary };
Assert::IsTrue(stream.is_open(), L"Could not read the captured output.");
std::string captured{ std::istreambuf_iterator<char>{ stream }, std::istreambuf_iterator<char>{} };
while (!captured.empty() && (captured.back() == '\r' || captured.back() == '\n'))
{
captured.pop_back();
}
return captured;
}
// The forwarded payload is deliberately ASCII-only so it round-trips through cmd.exe's ECHO.
std::string NarrowAscii(std::wstring_view text)
{
std::string narrow;
narrow.reserve(text.size());
for (const wchar_t character : text)
{
narrow.push_back(static_cast<char>(character));
}
return narrow;
}
}
namespace CliShimUnitTests
{
TEST_CLASS(LauncherIntegrationTests)
{
public:
TEST_METHOD(AllMappedCommandsLaunchExpectedRelativeTargets)
{
TemporaryDirectory installation;
const std::filesystem::path binDirectory = installation.GetPath() / L"bin";
const std::filesystem::path targetSource = GetSystemCommandInterpreter();
for (const ShimMapping& mapping : ExpectedMappings)
{
const std::filesystem::path shimPath = binDirectory / (std::wstring{ mapping.command } + L".exe");
const std::filesystem::path targetPath = (binDirectory / mapping.relativeTarget).lexically_normal();
CopyExecutable(GetShimUnderTest(), shimPath);
CopyExecutable(targetSource, targetPath);
const DWORD exitCode = RunAndGetExitCode(shimPath, L"/d /c exit 37");
const std::wstring message = L"Command failed: " + std::wstring{ mapping.command };
Assert::AreEqual(ForwardedExitCode, exitCode, message.c_str());
}
}
// cmd.exe's ECHO writes the remainder of its command line out verbatim, so this pins down
// that the caller's quoting and spacing survive the hop through the shim unchanged.
TEST_METHOD(ArgumentTailIsForwardedVerbatim)
{
constexpr const wchar_t* argumentTail = LR"(--path "C:\a b\c.png" --size 100 -q)";
TemporaryDirectory installation;
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
CopyExecutable(GetShimUnderTest(), shimPath);
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
DWORD exitCode = MAXDWORD;
const std::string captured = RunAndCaptureStandardOutput(
shimPath,
std::wstring{ L"/d /c echo " } + argumentTail,
installation.GetPath() / L"captured-output.txt",
exitCode);
Assert::AreEqual(static_cast<DWORD>(0), exitCode, L"The forwarded command did not succeed.");
Assert::AreEqual(NarrowAscii(argumentTail), captured, L"The argument tail was not forwarded verbatim.");
}
TEST_METHOD(UnknownCommandReturnsCommandNotMapped)
{
TemporaryDirectory installation;
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"unknown.exe";
CopyExecutable(GetShimUnderTest(), shimPath);
Assert::AreEqual(ExitCommandNotMapped, RunAndGetExitCode(shimPath));
}
TEST_METHOD(LegacyCommandsReturnCommandNotMapped)
{
TemporaryDirectory installation;
const std::filesystem::path binDirectory = installation.GetPath() / L"bin";
for (const wchar_t* command : RejectedLegacyCommands)
{
const std::filesystem::path shimPath = binDirectory / (std::wstring{ command } + L".exe");
CopyExecutable(GetShimUnderTest(), shimPath);
const std::wstring message = L"Legacy command was unexpectedly mapped: " + std::wstring{ command };
Assert::AreEqual(ExitCommandNotMapped, RunAndGetExitCode(shimPath), message.c_str());
}
}
// A missing target must not be reported with an exit code the target CLI itself could
// return, so callers can tell the two apart.
TEST_METHOD(MissingTargetReturnsTargetNotFound)
{
TemporaryDirectory installation;
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
CopyExecutable(GetShimUnderTest(), shimPath);
Assert::AreEqual(ExitTargetNotFound, RunAndGetExitCode(shimPath));
}
// The shim is the only handle a caller holds on the CLI, so a single-process kill of the
// shim must not leave the CLI running - PowerToys.FileLocksmith.CLI --wait polls forever
// and prints nothing, which would make the orphan invisible and permanent.
TEST_METHOD(TerminatingTheShimTerminatesTheTargetCli)
{
TemporaryDirectory installation;
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
CopyExecutable(GetShimUnderTest(), shimPath);
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
// PAUSE blocks inside the target itself, on a pipe that is never written, so the
// target needs no console and starts no worker that could outlive the test.
SECURITY_ATTRIBUTES attributes{};
attributes.nLength = sizeof(attributes);
attributes.bInheritHandle = TRUE;
HANDLE readEnd = nullptr;
HANDLE writeEnd = nullptr;
Assert::IsTrue(CreatePipe(&readEnd, &writeEnd, &attributes, 0) != FALSE, L"Could not create the standard input pipe.");
const UniqueHandle standardInput{ readEnd };
const UniqueHandle keepPipeOpen{ writeEnd };
const UniqueHandle standardOutput = OpenNul(GENERIC_WRITE);
LaunchedProcess launchedShim = StartProcess(shimPath, L"/d /c pause", standardInput.Get(), standardOutput.Get());
const DWORD shimProcessId = launchedShim.processId;
const ProcessKiller shim{ std::move(launchedShim.process) };
const ProcessKiller target{ WaitForProcessByImageName(
L"FancyZonesCLI.exe",
shimProcessId,
SYNCHRONIZE | PROCESS_TERMINATE,
10'000) };
Assert::IsTrue(target.Get() != nullptr, L"The shim did not start the target CLI.");
Assert::AreEqual(
static_cast<DWORD>(WAIT_TIMEOUT),
WaitForSingleObject(target.Get(), 200),
L"The target CLI exited before the shim was terminated.");
// taskkill without /T, Process.Kill() without entireProcessTree, a script's own
// timeout, stopping the debugger: all of these reach the shim and nothing else.
Assert::IsTrue(TerminateProcess(shim.Get(), 1) != FALSE, L"Could not terminate the shim.");
Assert::AreEqual(
static_cast<DWORD>(WAIT_OBJECT_0),
WaitForSingleObject(target.Get(), 10'000),
L"The target CLI outlived the shim.");
WaitForSingleObject(shim.Get(), 5'000);
}
// The mirror of the test above: JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK keeps the CLI's own
// children out of the job, so PowerToys.FancyZones.CLI open-settings can start a
// long-lived PowerToys.exe and return without that window dying with the shim.
TEST_METHOD(ProcessesStartedByTheTargetCliSurviveTheShim)
{
TemporaryDirectory installation;
const std::filesystem::path shimPath = installation.GetPath() / L"bin" / L"PowerToys.FancyZones.CLI.exe";
// Once the CLI that started it has exited, the survivor's parent process id refers to
// a process that no longer exists, so it can only be found by image name. Deriving
// that name from the temporary directory keeps concurrent test runs from colliding.
const std::wstring survivorName = installation.GetPath().stem().wstring() + L"-survivor.exe";
const std::filesystem::path survivorPath = installation.GetPath() / survivorName;
CopyExecutable(GetShimUnderTest(), shimPath);
CopyExecutable(GetSystemCommandInterpreter(), installation.GetPath() / L"FancyZonesCLI.exe");
CopyExecutable(GetSystemDirectoryPath() / L"PING.EXE", survivorPath);
// START returns as soon as the survivor is running, so the CLI - and with it the shim -
// exits while the survivor is still alive.
const std::wstring arguments = LR"(/d /c start "" /b ")" + survivorPath.wstring() + LR"(" -n 30 127.0.0.1 > nul)";
Assert::AreEqual(static_cast<DWORD>(0), RunAndGetExitCode(shimPath, arguments), L"The target CLI did not succeed.");
const ProcessKiller survivor{ WaitForProcessByImageName(
survivorName,
std::nullopt,
SYNCHRONIZE | PROCESS_TERMINATE,
10'000) };
Assert::IsTrue(survivor.Get() != nullptr, L"The process started by the target CLI was killed with the shim.");
Assert::AreEqual(
static_cast<DWORD>(WAIT_TIMEOUT),
WaitForSingleObject(survivor.Get(), 1'000),
L"The process started by the target CLI did not outlive the shim.");
}
};
}

40
tools/CliShim/CliShim.rc Normal file
View File

@@ -0,0 +1,40 @@
// 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.
#include <windows.h>
#include "../../src/common/version/version.h"
1 VERSIONINFO
FILEVERSION FILE_VERSION
PRODUCTVERSION FILE_VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "Microsoft Corporation"
VALUE "FileDescription", "PowerToys CLI command shim"
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", "PowerToys.CliShim"
VALUE "LegalCopyright", "Copyright (C) Microsoft Corporation. All rights reserved."
VALUE "OriginalFilename", "PowerToys.CliShim.exe"
VALUE "ProductName", "PowerToys"
VALUE "ProductVersion", FILE_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="NuGet">
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
<NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker>
<NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier>
<NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Globals">
<ProjectGuid>{8A7FB7FA-65EA-4004-BA73-1B237435A57B}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>PowerToysCliShim</RootNamespace>
<ProjectName>PowerToys.CliShim</ProjectName>
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\CliShim\</OutDir>
<TargetName>PowerToys.CliShim</TargetName>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<Import Project="CliShimManifest.props" />
<PropertyGroup>
<VcpkgEnabled>false</VcpkgEnabled>
<VcpkgManifestEnabled>false</VcpkgManifestEnabled>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(IntDir);%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="main.cpp" />
<ClCompile Include="CommandLine.cpp" />
<ClInclude Include="CommandLine.h" />
<ResourceCompile Include="CliShim.rc" />
<None Include="CliShimManifest.props" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\common\version\version.vcxproj">
<Project>{CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}</Project>
<LinkLibraryDependencies>false</LinkLibraryDependencies>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<!--
Keep the explicit WiX components synchronized with the shared manifest. This lives in the
product project rather than the wixproj so drift fails on any ordinary build instead of only
when someone builds the installer.
-->
<Target Name="ValidateCliShimInstallerManifest"
BeforeTargets="ClCompile"
Condition="'$(DesignTimeBuild)' != 'true'">
<PropertyGroup>
<_CliShimWxsPath>$(RepoRoot)installer\PowerToysSetupVNext\CliShims.wxs</_CliShimWxsPath>
<_CliShimWxsText>$([System.IO.File]::ReadAllText('$(_CliShimWxsPath)'))</_CliShimWxsText>
<!-- Anchored to <File> so unrelated Name="*.exe" attributes cannot inflate the count. -->
<_CliShimWxsCount>$([System.Text.RegularExpressions.Regex]::Matches('$(_CliShimWxsText)', '&lt;File\b[^&gt;]*\bName=&quot;[^&quot;]+\.exe&quot;').Count)</_CliShimWxsCount>
<_CliShimManifestCount>@(CliShim-&gt;Count())</_CliShimManifestCount>
</PropertyGroup>
<ItemGroup>
<CliShim>
<InWxs>$(_CliShimWxsText.Contains('Name=&quot;%(Identity).exe&quot;'))</InWxs>
</CliShim>
</ItemGroup>
<Error Condition="'%(CliShim.InWxs)' != 'True'"
Text="CliShim installer drift: command '%(CliShim.Identity)' is missing from CliShims.wxs." />
<Error Condition="'$(_CliShimWxsCount)' != '$(_CliShimManifestCount)'"
Text="CliShim installer drift: CliShims.wxs installs $(_CliShimWxsCount) command executables, but CliShimManifest.props defines $(_CliShimManifestCount)." />
</Target>
</Project>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) Microsoft Corporation. Licensed under the MIT license. -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- Single source of truth for the runtime mapping and installed command names. -->
<!-- RelativeTarget is resolved from the installed bin folder and must use '/' separators. -->
<ItemGroup>
<CliShim Include="PowerToys.FancyZones.CLI">
<RelativeTarget>../FancyZonesCLI.exe</RelativeTarget>
</CliShim>
<CliShim Include="PowerToys.ImageResizer.CLI">
<RelativeTarget>../WinUI3Apps/PowerToys.ImageResizerCLI.exe</RelativeTarget>
</CliShim>
<CliShim Include="PowerToys.FileLocksmith.CLI">
<RelativeTarget>../FileLocksmithCLI.exe</RelativeTarget>
</CliShim>
<CliShim Include="PowerToys.PowerDisplay.CLI">
<RelativeTarget>../WinUI3Apps/PowerToys.PowerDisplay.Cli.exe</RelativeTarget>
</CliShim>
</ItemGroup>
<!--
Emits the mapping above as a C++ initializer list. The shim and its unit tests both import
this file, so the tests always assert against the same table the shim was built from and a
new command cannot be added to one without the other picking it up.
-->
<Target Name="GenerateCliShimTargets"
BeforeTargets="ClCompile"
Inputs="$(MSBuildProjectFullPath);$(MSBuildThisFileFullPath)"
Outputs="$(IntDir)CliShimTargets.g.inc">
<!--
RelativeTarget is emitted into a C++ string literal verbatim, so a Windows-style separator
becomes an escape sequence: "..\WinUI3Apps\x.exe" fails to compile as C4129 (promoted to an
error by TreatWarningAsError), and "..\bin\x.exe" compiles into control characters instead.
Both diagnostics would point at the generated file rather than at this one, so reject the
separator here, where the fix is.
-->
<Error Condition="$([System.String]::Copy('%(CliShim.RelativeTarget)').Contains('\'))"
Text="CliShim RelativeTarget must use '/' separators: '%(CliShim.Identity)' is mapped to '%(CliShim.RelativeTarget)'." />
<ItemGroup>
<_CliShimTargetLine Include="{ L%22%(CliShim.Identity)%22, L%22%(CliShim.RelativeTarget)%22 }," />
</ItemGroup>
<MakeDir Directories="$(IntDir)" />
<WriteLinesToFile File="$(IntDir)CliShimTargets.g.inc"
Lines="@(_CliShimTargetLine)"
Overwrite="true" />
</Target>
</Project>

View File

@@ -0,0 +1,63 @@
// 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.
#include "CommandLine.h"
namespace CommandLine
{
std::wstring StripArgumentZero(std::wstring_view commandLine)
{
const auto isWhitespace = [](const wchar_t character) {
return character == L' ' || character == L'\t';
};
size_t index = 0;
// A non-shell CreateProcessW caller can prepend whitespace; without this skip the scan
// below stalls at index 0 and leaks argv[0] into the forwarded tail. This is a deliberate
// departure from the CRT, which would report an empty argv[0] instead.
while (index < commandLine.size() && isWhitespace(commandLine[index]))
{
++index;
}
// argv[0] is tokenized differently from every later argument, and the rule that matters is
// the CRT's, because that is what every target ends up parsing: FileLocksmithCLI is a
// native wmain, and the .NET CLIs receive their string[] from the apphost's wmain. The CRT
// (ucrt\startup\argv_parsing.cpp, parse_command_line) toggles an in-quotes flag on every
// quote while scanning argv[0] and ends the name at the first whitespace found outside
// quotes; a quote never terminates the name and a backslash never escapes one.
//
// CommandLineToArgvW is the odd one out - it ends a quoted argv[0] at the closing quote,
// with no toggling - so following it instead would leak the rest of the program name into
// the tail of a partially quoted command line such as
// `"%ProgramFiles%"\PowerToys\bin\PowerToys.FancyZones.CLI.exe arg`, which cmd.exe passes
// through verbatim. Matching the CRT is what makes the shim transparent: the target sees
// the exact tail it would have seen had the caller invoked it directly.
bool inQuotes = false;
while (index < commandLine.size())
{
const wchar_t character = commandLine[index];
++index;
if (character == L'"')
{
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && isWhitespace(character))
{
break;
}
}
while (index < commandLine.size() && isWhitespace(commandLine[index]))
{
++index;
}
return std::wstring{ commandLine.substr(index) };
}
}

View File

@@ -0,0 +1,19 @@
// 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.
#pragma once
#include <string>
#include <string_view>
namespace CommandLine
{
// Removes argv[0] the way the CRT tokenizes it - every quote toggles an in-quotes flag and the
// name ends at the first whitespace outside quotes, so neither a quote nor a backslash-escape
// terminates it - then trims the separating spaces/tabs and preserves the remaining
// command-line text verbatim. Note that CommandLineToArgvW uses a different rule for argv[0];
// the CRT's is the one the target CLIs actually parse. Leading whitespace is skipped first,
// which the CRT does not do; see CommandLine.cpp.
std::wstring StripArgumentZero(std::wstring_view commandLine);
}

166
tools/CliShim/main.cpp Normal file
View File

@@ -0,0 +1,166 @@
// 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.
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <wil/resource.h>
#include <wil/stl.h>
#include <wil/win32_helpers.h>
#include <cstdio>
#include <filesystem>
#include <string>
#include "CommandLine.h"
namespace
{
// Match cmd.exe's "command not found" exit code for unmapped shim names.
constexpr int ExitCommandNotMapped = 9009;
// Shim failures use codes outside the range the target CLIs use for themselves (see
// doc/devdocs/cli-conventions.md: 0 success, 1 general error, 2 invalid arguments) so that a
// caller can tell "the shim could not run the CLI" apart from "the CLI ran and failed".
constexpr int ExitTargetNotFound = 9010;
constexpr int ExitLaunchFailed = 9011;
struct ShimTarget
{
const wchar_t* name;
const wchar_t* target;
};
// Generated from CliShimManifest.props.
constexpr ShimTarget ShimTargets[] = {
#include "CliShimTargets.g.inc"
};
// The child receives Ctrl+C/Break; keep the shim alive to return its exit code.
BOOL WINAPI ConsoleCtrlHandler(DWORD /*controlType*/)
{
return TRUE;
}
// The shim is the only handle a caller holds on the CLI, so killing the shim - taskkill without
// /T, Process.Kill() without entireProcessTree, a script's own timeout, stopping the debugger -
// must not leave the CLI running: PowerToys.FileLocksmith.CLI --wait polls until interrupted and
// prints nothing while it does. KILL_ON_JOB_CLOSE takes the CLI down with the shim, because the
// kernel closes the job handle however the shim dies.
//
// SILENT_BREAKAWAY_OK keeps the CLI's own children out of the job: PowerToys.FancyZones.CLI
// open-settings starts a long-lived PowerToys.exe and returns, and without this flag that window
// would be killed the moment the shim exits. Plain BREAKAWAY_OK cannot substitute - it requires
// the creator to pass CREATE_BREAKAWAY_FROM_JOB, which Process.Start cannot express. This is the
// one deliberate difference from src\runner\quick_access_host.cpp.
wil::unique_handle CreateShimJob()
{
wil::unique_handle job{ CreateJobObjectW(nullptr, nullptr) };
if (!job)
{
return {};
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK;
if (!SetInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
{
// A job without the kill limit buys nothing, so drop it rather than assign to it.
return {};
}
return job;
}
const wchar_t* ResolveTarget(const std::wstring& commandName)
{
for (const ShimTarget& entry : ShimTargets)
{
if (CompareStringOrdinal(commandName.c_str(), -1, entry.name, -1, TRUE) == CSTR_EQUAL)
{
return entry.target;
}
}
return nullptr;
}
}
int wmain()
{
SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE);
std::wstring modulePath;
if (FAILED(wil::GetModuleFileNameW(nullptr, modulePath)))
{
std::fwprintf(stderr, L"cli-shim: could not determine the shim's own path.\n");
return ExitLaunchFailed;
}
const std::filesystem::path selfPath{ modulePath };
const std::wstring commandName = selfPath.stem().wstring();
const wchar_t* relativeTarget = ResolveTarget(commandName);
if (relativeTarget == nullptr)
{
std::fwprintf(stderr, L"cli-shim: no PowerToys CLI is mapped to the command '%s'.\n", commandName.c_str());
return ExitCommandNotMapped;
}
const std::filesystem::path targetPath = (selfPath.parent_path() / relativeTarget).lexically_normal();
std::error_code existsError;
if (!std::filesystem::exists(targetPath, existsError))
{
std::fwprintf(stderr, L"cli-shim: target not found: \"%s\".\n", targetPath.c_str());
return ExitTargetNotFound;
}
// Forward the raw tail so the caller's argument quoting remains unchanged.
const std::wstring forwardedArguments = CommandLine::StripArgumentZero(GetCommandLineW());
// lpApplicationName selects the target; argv[0] in the command line is cosmetic.
std::wstring commandLine = L'"' + targetPath.wstring() + L'"';
if (!forwardedArguments.empty())
{
commandLine.push_back(L' ');
commandLine.append(forwardedArguments);
}
STARTUPINFOW startupInfo{};
startupInfo.cb = sizeof(startupInfo);
wil::unique_process_information processInfo;
// Best effort, and silent on failure: an unprotected CLI beats a CLI that will not start, and
// this process's stderr belongs to the CLI's caller.
const wil::unique_handle shimJob = CreateShimJob();
if (!CreateProcessW(
targetPath.c_str(),
commandLine.data(), // Requires a mutable buffer; CreateProcessW may write to it.
nullptr,
nullptr,
TRUE, // Inherit handles: share stdin/stdout/stderr and stay in this console.
0,
nullptr,
nullptr,
&startupInfo,
&processInfo))
{
std::fwprintf(stderr, L"cli-shim: failed to launch \"%s\" (error %lu).\n", targetPath.c_str(), GetLastError());
return ExitLaunchFailed;
}
if (shimJob)
{
AssignProcessToJobObject(shimJob.get(), processInfo.hProcess);
}
WaitForSingleObject(processInfo.hProcess, INFINITE);
DWORD exitCode = static_cast<DWORD>(ExitLaunchFailed);
GetExitCodeProcess(processInfo.hProcess, &exitCode);
return static_cast<int>(exitCode);
}

View File

@@ -383,6 +383,29 @@ try {
RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration
}
$cliShimPath = Join-Path $buildOutputPath 'CliShim\PowerToys.CliShim.exe'
if (-not (Test-Path -LiteralPath $cliShimPath -PathType Leaf)) {
Write-Error "CLI shim not found at '$cliShimPath'. Build PowerToys.slnx (tools\CliShim\CliShim.vcxproj) before building the installer."
exit 1
}
# Nothing else validates the target half of the shim manifest: CliShim.vcxproj only compares
# command names against CliShims.wxs, and the launcher tests fabricate their target at whatever
# path the manifest declares, so they cannot falsify it. The target CLIs are harvested into the
# MSI by directory glob rather than authored by hand, so moving or renaming one leaves every
# other gate green and ships a shim that exits 9010 for every user. This is the first point where
# the whole product tree exists, so resolve each mapping here exactly the way the shim will at
# run time - relative to its own bin folder - against the layout the build output mirrors.
$cliShimBinDir = Join-Path $buildOutputPath 'bin'
$cliShimManifest = [xml](Get-Content -LiteralPath (Join-Path $repoRoot 'tools\CliShim\CliShimManifest.props') -Raw)
foreach ($shim in $cliShimManifest.Project.ItemGroup.CliShim) {
$resolvedTarget = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($cliShimBinDir, $shim.RelativeTarget))
if (-not (Test-Path -LiteralPath $resolvedTarget -PathType Leaf)) {
Write-Error "CLI shim '$($shim.Include)' maps to '$($shim.RelativeTarget)', which resolves to '$resolvedTarget' and does not exist. Update tools\CliShim\CliShimManifest.props to match where that CLI is built."
exit 1
}
}
# Set NUGET_PACKAGES environment variable if not set, to help wixproj find heat.exe
if (-not $env:NUGET_PACKAGES) {
$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE ".nuget\packages"