<?xml version="1.0" encoding="utf-8" standalone="no"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-au"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://stealthpuppy.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://stealthpuppy.com/" hreflang="en-au" rel="alternate" type="text/html"/><updated>2026-04-03T02:01:48+00:00</updated><id>https://stealthpuppy.com/feed.xml</id><title type="html">Aaron Parker</title><subtitle>Aaron Parker, Citrix Technology Professional, on End User Computing and Enterprise Mobility</subtitle><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><xhtml:meta content="noindex" name="robots" xmlns:xhtml="http://www.w3.org/1999/xhtml"/><entry><title type="html">Introducing the Evergreen Workbench</title><link href="https://stealthpuppy.com/evergreen-workbench/" rel="alternate" title="Introducing the Evergreen Workbench" type="text/html"/><published>2026-03-18T23:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/evergreen-workbench</id><content type="html" xml:base="https://stealthpuppy.com/evergreen-workbench/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#why-a-workbench" id="markdown-toc-why-a-workbench">Why a Workbench?</a></li>
  <li><a href="#the-workbench-editions" id="markdown-toc-the-workbench-editions">The Workbench editions</a></li>
  <li><a href="#the-web-workbench" id="markdown-toc-the-web-workbench">The Web Workbench</a>    <ul>
      <li><a href="#dashboard" id="markdown-toc-dashboard">Dashboard</a></li>
      <li><a href="#browsing-and-filtering-apps" id="markdown-toc-browsing-and-filtering-apps">Browsing and filtering apps</a></li>
      <li><a href="#searching" id="markdown-toc-searching">Searching</a></li>
      <li><a href="#other-features" id="markdown-toc-other-features">Other features</a></li>
    </ul>
  </li>
  <li><a href="#the-desktop-workbench" id="markdown-toc-the-desktop-workbench">The Desktop Workbench</a>    <ul>
      <li><a href="#installing-and-launching" id="markdown-toc-installing-and-launching">Installing and launching</a></li>
      <li><a href="#apps-and-downloads" id="markdown-toc-apps-and-downloads">Apps and downloads</a></li>
      <li><a href="#library-management" id="markdown-toc-library-management">Library management</a></li>
      <li><a href="#import---microsoft-intune-and-nerdio-manager" id="markdown-toc-import---microsoft-intune-and-nerdio-manager">Import - Microsoft Intune and Nerdio Manager</a></li>
      <li><a href="#install" id="markdown-toc-install">Install</a></li>
      <li><a href="#settings" id="markdown-toc-settings">Settings</a></li>
    </ul>
  </li>
  <li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>

<p><a href="https://stealthpuppy.com/evergreen">Evergreen</a> has been around for a little over six years now. What started as a handful of functions to retrieve application version data has grown into a module that today tracks more than 500 applications. Over that time, I’ve also built several solutions that integrate Evergreen into packaging workflows - automating downloads, managing Evergren libraries, importing packages into <a href="https://stealthpuppy.com/packagefactory/">Microsoft Intune</a>, and more recently into <a href="https://stealthpuppy.com/nerdio-shell-apps-p1/">Nerdio Manager Shell Apps</a>.</p>

<p>All of that has always lived firmly within an automation framework, PowerShell and the command-line. If you know PowerShell and know the module, it’s powerful. If you’re new to it - or you’re looking to understand Evergreen’s capabilities - the entry point can feel steep. I’ve been thinking about this for a while, and I’m excited to share two new graphical interfaces for Evergreen that I hope will make the module’s capabilities more visible and accessible.</p>

<p>I’m calling them the <strong>Evergreen Workbench</strong> - available in two editions: a <strong>Desktop Workbench</strong> for Windows, and a <strong>Web Workbench</strong> that runs in any modern browser. These UIs won’t replace existing PowerShell-based use of Evergreen; they wrap the same cmdlets and data behind an interactive interface. Think of them as a front door to functionality that was previously only available to those comfortable in a terminal.</p>

<p>This is still early days, particularly for the Desktop Workbench, and I’d love feedback and contributions as these tools mature.</p>

<p class="note" title="In development">Most of the desktop and web Workbench has been written with the help of Claude and GitHub Copilot. Given how complex these two features are, it’s likely that most of the development will continue this way.</p>

<h2 id="why-a-workbench">Why a Workbench?</h2>

<p>This is something I’ve wanted to do for some time, but the honest answer is that I’ve accumulated a collection of automation solutions that use Evergreen under the hood - pipelines for building Intune Win32 packages, scripts for updating Nerdio Manager Shell Apps, library management workflows, and more. These are powerful, but they’re scattered, and they each require some setup to understand and use.</p>

<p>The Workbench is an attempt to aggregate that functionality into a single place that’s easier to discover and use. Rather than knowing the right cmdlet or digging through GitHub repos to find the right script, the goal is to make that functionality visible in a UI - whether that’s browsing available app versions, managing a local library, or eventually importing a package into Intune or Nerdio Manager.</p>

<p>The Web Workbench takes a different angle - it provides a read-only view of all Evergreen-tracked applications without requiring PowerShell and can run on any platform. It’s useful for looking up a download URL, checking what versions are tracked, or keeping an eye on recent application updates via RSS. This is essentially a new version of the Evergreen App Tracker.</p>

<h2 id="the-workbench-editions">The Workbench editions</h2>

<p>Both editions are open source and available on GitHub:</p>

<ul>
  <li><strong>Desktop Workbench:</strong> <a href="https://github.com/EUCPilots/evergreen-ui">EUCPilots/evergreen-ui</a></li>
  <li><strong>Web Workbench:</strong> <a href="https://github.com/EUCPilots/workbench">EUCPilots/workbench</a></li>
</ul>

<p>Here’s how they compare:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Desktop Workbench</th>
      <th>Web Workbench</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Platform</strong></td>
      <td>Windows</td>
      <td>Any modern browser</td>
    </tr>
    <tr>
      <td><strong>Install</strong></td>
      <td>PowerShell Gallery (<code class="language-plaintext highlighter-rouge">EvergreenUI</code>)</td>
      <td>Hosted at <a href="https://eucpilots.com/workbench">https://eucpilots.com/workbench</a></td>
    </tr>
    <tr>
      <td><strong>Browse apps</strong></td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Search and filter</strong></td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Dashboard</strong></td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Download installers</strong></td>
      <td>Yes</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Library management</strong></td>
      <td>Yes</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Install / update apps</strong></td>
      <td>In development</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Import to Intune / Nerdio</strong></td>
      <td>In development</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Export to CSV</strong></td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>RSS feeds</strong></td>
      <td>No</td>
      <td>Yes (per app)</td>
    </tr>
    <tr>
      <td><strong>PWA install</strong></td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Theme</strong></td>
      <td>Light / Dark</td>
      <td>Light / Dark</td>
    </tr>
  </tbody>
</table>

<p>Full documentation for both editions is available at <a href="https://eucpilots.com/evergreen-docs/workbench">https://eucpilots.com/evergreen-docs/workbench</a>.</p>

<h2 id="the-web-workbench">The Web Workbench</h2>

<p>The Web Workbench is the easiest place to start - no installation required. Open <a href="https://eucpilots.com/workbench">https://eucpilots.com/workbench</a> in any browser and you have access to all Evergreen-tracked application data.</p>

<h3 id="dashboard">Dashboard</h3>

<p>The Dashboard gives you an at-a-glance view of everything Evergreen tracks.</p>

<p><a href="/media/2026/03/webui/webworkbench-dashboard.jpeg"><img src="/media/2026/03/webui/webworkbench-dashboard.jpeg" alt="The Web Workbench Dashboard showing summary statistics, charts, and recent activity" /></a></p>

<p class="figcaption">The Web Workbench Dashboard.</p>

<p>At the top you’ll see the headline numbers: total applications tracked, total version records, distinct architectures, installer file types, and applications updated in the last 48 hours. Below that, bar charts break down the data by CPU architecture and installer file type - useful for getting a sense of what Evergreen is actually tracking. There’s also a URI lookup field where you can paste a download URL to find which application it belongs to, and a recent activity list showing the applications with the most recent data updates.</p>

<h3 id="browsing-and-filtering-apps">Browsing and filtering apps</h3>

<p>The Apps view lists all Evergreen-supported applications in a sidebar with version detail on the right. Select an application to view its version entries - version string, language, file size, architecture, and direct download URI.</p>

<p><a href="/media/2026/03/webui/webworkbench-apps.jpeg"><img src="/media/2026/03/webui/webworkbench-apps.jpeg" alt="The Apps view showing Adobe Acrobat Reader DC with version detail columns" /></a></p>

<p class="figcaption">The Apps view with version detail for the selected application.</p>

<p>Each column in the version table has a text filter below the header, and checkbox filters at the top of the pane let you narrow results by architecture and file type. These filters work together - for example, you can select x64 and type “English” in the Language column to see only English x64 installers.</p>

<p><a href="/media/2026/03/webui/webworkbench-filter.jpeg"><img src="/media/2026/03/webui/webworkbench-filter.jpeg" alt="The Apps view with a Language column filter applied showing only English results" /></a></p>

<p class="figcaption">Column filters and architecture checkboxes working together to narrow results.</p>

<p>The toolbar above the table includes a copy button for the <code class="language-plaintext highlighter-rouge">Get-EvergreenApp</code> PowerShell command to retrieve the same data - making it simpler to discover function usage in Evergreen.</p>

<h3 id="searching">Searching</h3>

<p>The sidebar search filters the application list by name as you type.</p>

<p><a href="/media/2026/03/webui/webworkbench-searchapps.jpeg"><img src="/media/2026/03/webui/webworkbench-searchapps.jpeg" alt="The Apps view with the sidebar filtered to show only Microsoft Edge applications" /></a></p>

<p class="figcaption">Sidebar search filtering the application list by name.</p>

<p>For a broader search, press <strong>Ctrl+K</strong> to open the global search overlay. This searches across all application names and download URIs - useful when you have a URL and want to know which app it belongs to.</p>

<p><a href="/media/2026/03/webui/webworkbench-search.jpeg"><img src="/media/2026/03/webui/webworkbench-search.jpeg" alt="The global search overlay showing results across application names and URIs" /></a></p>

<p class="figcaption">The global search overlay finds matches across app names and download URIs.</p>

<h3 id="other-features">Other features</h3>

<p>A few additional features worth noting:</p>

<ul>
  <li><strong>RSS feeds</strong> - each application has an RSS feed available from the toolbar. If you want to be notified when a specific application’s version data changes, subscribe to its feed.</li>
  <li><strong>PWA install</strong> - the Web Workbench is a Progressive Web App. Use your browser’s install option to add it to your desktop or home screen for a standalone app experience on Windows, macOS, Linux, or mobile.</li>
  <li><strong>Export to CSV</strong> - export the current filtered view to a CSV file.</li>
  <li><strong>Light and dark themes</strong> - toggle with the sun/moon icon in the top-right corner.</li>
</ul>

<h2 id="the-desktop-workbench">The Desktop Workbench</h2>

<p>The Desktop Workbench is a WPF-based application that ships as the <code class="language-plaintext highlighter-rouge">EvergreenUI</code> PowerShell module. It wraps the same Evergreen cmdlets - <code class="language-plaintext highlighter-rouge">Get-EvergreenApp</code>, <code class="language-plaintext highlighter-rouge">Save-EvergreenApp</code>, <code class="language-plaintext highlighter-rouge">Start-EvergreenLibraryUpdate</code>, and others - behind an interactive Windows desktop interface.</p>

<p class="note" title="Beta">Note that this is currently a pre-release module, so features and commands may change before the stable release.</p>

<h3 id="installing-and-launching">Installing and launching</h3>

<p>The <code class="language-plaintext highlighter-rouge">EvergreenUI</code> module is published to the PowerShell Gallery. Because it’s currently pre-release, you need the <code class="language-plaintext highlighter-rouge">-AllowPrerelease</code> flag:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Install-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nx">EvergreenUI</span><span class="w"> </span><span class="nt">-AllowPrerelease</span><span class="w">
</span></code></pre></div></div>

<p>The Evergreen module is listed as a dependency, so if you don’t already have it installed, PowerShell will pull it in automatically.</p>

<p>Once installed, import the module and run <code class="language-plaintext highlighter-rouge">Start-EvergreenWorkbench</code>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Import-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nx">EvergreenUI</span><span class="w">
</span><span class="n">Start-EvergreenWorkbench</span><span class="w">
</span></code></pre></div></div>

<p>The workbench opens to the Apps view by default. You can change the startup view and other preferences in Settings.</p>

<p>Requirements are Windows 10 or Server 2019 or later, PowerShell 5.1 or 7.0+, and .NET Framework 4.7.2+ (for PowerShell 5.1) or .NET 6+ (for PowerShell 7+). Full installation details are in the <a href="https://eucpilots.com/evergreen-docs/workbench">documentation</a>.</p>

<h3 id="apps-and-downloads">Apps and downloads</h3>

<p>The Apps view displays all 500+ Evergreen-supported applications in a searchable list. Select an application to view its version and download metadata in the data grid on the right.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-apps.png"><img src="/media/2026/03/ui/evergreen-workbench-apps.png" alt="The Apps view showing Microsoft applications with channel, architecture, and file type data" /></a></p>

<p class="figcaption">The Desktop Workbench Apps view.</p>

<p>The filter panel updates dynamically based on the properties that application returns. Selecting Adobe Acrobat Reader DC, for example, shows filters for Language, Architecture, and File type. Selecting Microsoft Edge shows Channel and Architecture filters. The available filter properties vary by application and can include architecture, channel, ring, language, installer type, and release.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-apps-filter.png"><img src="/media/2026/03/ui/evergreen-workbench-apps-filter.png" alt="Dynamic filters for Adobe Acrobat Reader DC showing Language, Architecture, and File type controls" /></a></p>

<p class="figcaption">The filter panel updates dynamically based on the selected application’s properties.</p>

<p>To download an installer, select versions in the Apps view and click <strong>Add to download queue</strong>, then switch to the Download view to manage and start downloads.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-download.png"><img src="/media/2026/03/ui/evergreen-workbench-download.png" alt="The Download view showing a queued download for Adobe Acrobat Reader DC" /></a></p>

<p class="figcaption">The Download view showing the download queue and progress.</p>

<p>Downloads are processed sequentially and tracked with a progress bar. You can remove individual items, clear the queue, or open the output folder from the toolbar.</p>

<h3 id="library-management">Library management</h3>

<p>If you have an existing Evergreen library, the Library view provides a GUI for browsing and updating it. Browse to your library path to load the contents - a table of applications with version counts and paths, and version detail for the selected application which will include the details for that application.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-library.png"><img src="/media/2026/03/ui/evergreen-workbench-library.png" alt="The Library view showing library contents and version details for Microsoft Edge" /></a></p>

<p class="figcaption">The Library view for managing an existing Evergreen library.</p>

<p>From the toolbar you can create a new library (<code class="language-plaintext highlighter-rouge">New-EvergreenLibrary</code>), refresh the contents (<code class="language-plaintext highlighter-rouge">Get-EvergreenLibrary</code>), or update the library with the latest application versions (<code class="language-plaintext highlighter-rouge">Start-EvergreenLibraryUpdate</code>). These map directly to the PowerShell cmdlets you’d run manually, just surfaced in the UI.</p>

<h3 id="import---microsoft-intune-and-nerdio-manager">Import - Microsoft Intune and Nerdio Manager</h3>

<p class="note" title="In development">The Nerdio Manager Shell Apps import is functional but not yet validated in production. The Microsoft Intune import is still in development.</p>

<p>The Import tab is where some of the more ambitious integration work lives, with sub-tabs for Microsoft Intune Win32 Apps and Nerdio Manager Shell Apps.</p>

<p>The idea here is that you point the workbench at a directory containing package definitions, it compares those definitions against what’s currently in your Microsoft Intune tenant or Nerdio Manager environment, and you can import new apps or update existing ones from the UI.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-import-intune.png"><img src="/media/2026/03/ui/evergreen-workbench-import-intune.png" alt="The Import tab showing Microsoft Intune Win32 Apps with package definitions and import status" /></a></p>

<p class="figcaption">The Microsoft Intune import view comparing package definitions against apps in the tenant.</p>

<p>I’ll have more to share on the package definitions when these features are further down the track, but the intent is to create a unified package definition that works for Intune and Nerdio Manager.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-import-nerdio.png"><img src="/media/2026/03/ui/evergreen-workbench-import-nerdio.png" alt="The Import tab showing Nerdio Manager Shell Apps with version comparison" /></a></p>

<p class="figcaption">The Nerdio Manager Shell Apps import view.</p>

<p>Authentication to Entra ID, the Nerdio Manager API, and optionally Azure Storage is managed via the Authentication sub-tab.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-import-auth.png"><img src="/media/2026/03/ui/evergreen-workbench-import-auth.png" alt="The Authentication sub-tab for configuring connections to Entra ID, Nerdio Manager, and Azure Storage" /></a></p>

<p class="figcaption">The Authentication sub-tab for configuring tenant and API connections.</p>

<p>This is the integration work that is the most complex components, but also the area that still needs the most development and testing. If you’re using Intune and/or Nerdio Manager and want to help test or contribute, I’d love to hear from you.</p>

<h3 id="install">Install</h3>

<p class="note" title="In development">This feature is in development and may not function as expected.</p>

<p>The Install view compares package definitions against what’s currently installed on the machine, letting you install or update applications directly from the workbench.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-install.png"><img src="/media/2026/03/ui/evergreen-workbench-install.png" alt="The Install view comparing installed and latest application versions" /></a></p>

<p class="figcaption">The Install view comparing installed versions against the latest versions available from Evergreen.</p>

<p>Point the workbench at a directory containing package definitions and click <strong>Load definitions</strong>. The view shows each application’s name and architecture, the currently installed version (if any), the latest version available from Evergreen, and a status indicating whether it’s up to date, needs an update, or isn’t installed. From there you can select applications and click <strong>Install selected</strong> to install or update them. If the workbench isn’t running elevated, installers may prompt for UAC elevation.</p>

<p>The Install view is hidden by default - enable it in Settings.</p>

<h3 id="settings">Settings</h3>

<p>The Settings view covers general preferences - download output path, theme, and which views appear in the navigation - alongside provider-specific configuration for Nerdio Manager and Intune. The Import and Install tabs are hidden by default until these features are working as expected.</p>

<p><a href="/media/2026/03/ui/evergreen-workbench-settings.png"><img src="/media/2026/03/ui/evergreen-workbench-settings.png" alt="The Settings view showing General, Nerdio Manager, and Microsoft Intune configuration" /></a></p>

<p class="figcaption">The Settings view for configuring general preferences and provider connections.</p>

<h2 id="wrap-up">Wrap Up</h2>

<p>The Evergreen Workbench is something I’ve wanted to build for a while - a way to make Evergreen’s capabilities more visible and accessible without replacing the PowerShell workflows that many environments already rely on. The Web Workbench is production-ready and a good starting point if you want a quick look at what Evergreen tracks. The Desktop Workbench is still in pre-release, but already functional for browsing apps, managing downloads, and working with libraries.</p>

<p>Full documentation for both editions is at <a href="https://eucpilots.com/evergreen-docs/workbench">https://eucpilots.com/evergreen-docs/workbench</a>.</p>

<p>If you try either edition and run into issues, have ideas for features, or want to help with development - particularly around the Intune and Nerdio Manager integrations - I’d love to hear from you. Leave a comment below or open an issue or Pull Request on GitHub. It’s been more than six years since Evergreen’s first release, and I think this is a genuinely exciting new direction for the project.</p>

<p>Is this turning Evergreen into a Windows package manager? I’m inclined to say no, even though much of the Workbench functionality does what package managers do. Like Evergreen itself, I’ve had a “build it, and they will come” mentality, which probably isn’t ideal way to approach any sort of product. Luckily, Evergreen is for the community and likewise the Workbench.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="Automation"/><category term="PowerShell"/><summary type="html"><![CDATA[After more than six years, Evergreen now has a graphical interface. Here's a look at the Evergreen Workbench - a Windows desktop app and a browser-based web app for exploring application version data.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/workbench/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/workbench/image.jpg"/></entry><entry><title type="html">Securing AVD and Windows 365 with Strong Authentication</title><link href="https://stealthpuppy.com/avd-w365-secure-authentication/" rel="alternate" title="Securing AVD and Windows 365 with Strong Authentication" type="text/html"/><published>2025-09-22T03:45:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/avd-w365-authentication</id><content type="html" xml:base="https://stealthpuppy.com/avd-w365-secure-authentication/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#entra-conditional-access-policy-details" id="markdown-toc-entra-conditional-access-policy-details">Entra Conditional Access policy details</a>    <ul>
      <li><a href="#assignments" id="markdown-toc-assignments">Assignments</a></li>
      <li><a href="#access-controls" id="markdown-toc-access-controls">Access controls</a></li>
    </ul>
  </li>
  <li><a href="#user-experience" id="markdown-toc-user-experience">User Experience</a></li>
  <li><a href="#why-no-prompts-for-re-authn" id="markdown-toc-why-no-prompts-for-re-authn">Why No Prompts for Re-Authn?</a></li>
  <li><a href="#addressing-secure-requirements" id="markdown-toc-addressing-secure-requirements">Addressing Secure Requirements</a></li>
  <li><a href="#improving-the-end-user-experience" id="markdown-toc-improving-the-end-user-experience">Improving the End-user Experience</a></li>
</ul>

<p>Here’s a quick post on configuring strong authentication requirements for Azure Virtual Desktop and Windows 365 using Entra Conditional Access.</p>

<p>Customers may want to protect these virtual desktop resources because these desktops provide access to sensitive resources. For example, you could be using AVD as a protected administrator workstation or providing access to internal applications. To provide confidence that these resources are protected, strong authentication requirements are needed.</p>

<p>In this article, I’ll walk through configuring a Conditional Access policy that requires strong multi-factor authentication every time these resources are accessed, to govern virtual desktop access.</p>

<p>This configuration is specifically tailored for high security requirements, so I’ll also demonstrate the client experience with this policy in place, along with some considerations.</p>

<h2 id="entra-conditional-access-policy-details">Entra Conditional Access policy details</h2>

<h3 id="assignments">Assignments</h3>

<p>To configure authentication requirements, let’s create a new <a href="https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-policies">Conditional Access policy</a>:</p>

<ul>
  <li><strong>Policy name</strong> - I’ve given this policy a descriptive name: <strong>Require strong authentication for Azure Virtual Desktop and Windows 365</strong></li>
  <li><strong>Users</strong> - I want to target <strong>All Users</strong> with this policy so that any user with access to AVD or Windows 365 is covered with strong authentication. We could exclude a break glass account in this policy; however, there may be other methods to access to Entra ID; therefore there may not be a need to exclude specific accounts.</li>
</ul>

<p><img src="/media/2025/09/ca-users.jpeg" alt="" /></p>

<ul>
  <li><strong>Target resources</strong> - select <strong>Azure Virtual Desktop</strong> and <strong>Windows 365</strong>. If your tenant was previously enabled for Windows Virtual Desktop, you may see that application instead of Azure Virtual Desktop.</li>
</ul>

<p><img src="/media/2025/09/ca-targetresources.jpeg" alt="" /></p>

<ul>
  <li><strong>Network</strong> - select <strong>Any network or location</strong>. The intention of this policy will be to require strong authentication regardless of location. The network you are connected to should not be an indicator of trust in this scenario.</li>
</ul>

<p><img src="/media/2025/09/ca-network.jpeg" alt="" /></p>

<h3 id="access-controls">Access controls</h3>

<ul>
  <li><strong>Grant</strong> - select <strong>Grant access</strong>, choose <strong>Require authentication strength</strong> and select <strong>Phishing-resistant MFA</strong>, then choose <strong>For multiple controls / Require all the selected controls</strong>. The last setting is not necessarily required but a good practice in the event this policy is updated with additional controls.</li>
</ul>

<p><img src="/media/2025/09/ca-grant.jpeg" alt="" /></p>

<ul>
  <li><strong>Session</strong> - select <strong>Sign-in frequency</strong> and then choose <strong>Every time</strong>. This will require reauthentication each time these resources are accessed and will impact end users who need to provide MFA responses.</li>
</ul>

<p><img src="/media/2025/09/ca-session.jpeg" alt="" /></p>

<h2 id="user-experience">User Experience</h2>

<p>Here’s a look at the end-user experience. In this demo, I’m signing into the Windows 365 web client and authenticating with a FIDO2 key for strong authentication and connecting to my Cloud PC a couple of times. You’ll see that the sign-in experience is fast and simple, but I am not asked to reauthenticate each time I launch a Cloud PC:</p>

<video controls="">
  <source src="/media/2025/09/windows-app-experience.webm" type="video/webm" />
Your browser does not support the video tag.
</video>

<p>Let’s try again after a period of time - this time we can see that I am asked to re-authenticate to access my Cloud PC:</p>

<video controls="">
  <source src="/media/2025/09/windows-app-reauth.webm" type="video/webm" />
Your browser does not support the video tag.
</video>

<h2 id="why-no-prompts-for-re-authn">Why No Prompts for Re-Authn?</h2>

<p>At first glance, it might look like our policy is not working and is allowing the user to re-launch their desktop without being re-authenticated. Behind the scenes, the user still has a valid Entra ID token with a claim for strong authentication which satisfies the requirement.</p>

<p><img src="/media/2025/09/entraid-success.jpeg" alt="" /></p>

<p>This access still works because Entra ID tokens have a minimum lifetime of 10 mins as listed here: <a href="https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes#access-id-and-saml2-token-lifetime-policy-properties">Access, ID, and SAML2 token lifetime policy properties</a>. This document covers an in-preview feature configurable token lifetimes and has an important consideration for these lifetimes:</p>

<blockquote>
  <p>Reducing the Access Token Lifetime property mitigates the risk of an access token or ID token being used by a malicious actor for an extended period of time. (These tokens can’t be revoked.) The trade-off is that performance is adversely affected, because the tokens have to be replaced more often.</p>
</blockquote>

<p>So after 10 minutes, the lifetime expires and the user is asked to re-authenticate. For all but the most restrictive customer environments, this behaviour should be OK.</p>

<h2 id="addressing-secure-requirements">Addressing Secure Requirements</h2>

<p>In scenarios where the requirement for re-authentication cannot be met and access to Azure Virtual Desktop or Windows 365 needs to be protected, you could consider implementing additional requirements:</p>

<p><strong>Grant</strong> controls - where you are protecting access to sensitive information and privileged access workstations, select <strong>Require device to be marked as compliant</strong> in addition to <strong>Require authentication strength</strong>. This ensures that access is only allowed from a trusted, managed device that meets Intune compliance policies.</p>

<p><strong>Session</strong> controls - enable <strong>Require token protection for sign-in sessions</strong>. This feature can further protect from token theft, and supports the Windows App on a Windows client OS now (with macOS in preview): <a href="https://learn.microsoft.com/en-au/entra/identity/conditional-access/concept-token-protection">Token Protection in Microsoft Entra Conditional Access</a>.</p>

<p>Enabling this session control also requires you to update the CA policy to support only Windows, macOS, and iOS (optionally, with a separate policy that blocks other platforms). This requirement will also prevent users from using the Windows 365 web client. I did encounter issues getting this to work with the current Windows app on macOS (I haven’t tested with a preview client).</p>

<h2 id="improving-the-end-user-experience">Improving the End-user Experience</h2>

<p>This type of policy is typically required for highly secure environments and isn’t necessarily used to support general access to Azure Virtual Desktop or Windows 365 for most users. To improve the authentication experience, here are few considerations:</p>

<ul>
  <li><strong>Scope the policy</strong> to Entra ID administrator roles - policies that include strict sign-in frequency requirements would be best scoped to accounts with privileged roles with a separate policy for longer sign-in frequency for general users.</li>
  <li>Use <strong>separate Azure Virtual Desktop host pools</strong> for general end-users and administrator accounts. Users with access to both could then have a simplified sign-in experience on a corporate desktop with stricter sign-in to an administrator desktop.</li>
  <li>All users should use at least <strong>password-less authentication with the Microsoft Authenticator</strong> to speed, simplify and secure sign-ins.</li>
  <li>Don’t use <strong>Sign-in frequency</strong> with high sign-in frequencies without <strong>number matching</strong> or <strong>password-less authentication</strong> in the Microsoft Authenticator. Making it easier on users to sign-in will help balance experience with security.</li>
  <li>Follow the recommended authentication scenarios from Microsoft: <a href="https://learn.microsoft.com/en-au/entra/identity/conditional-access/concept-session-lifetime">Conditional Access adaptive session lifetime policies</a>.</li>
</ul>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Microsoft"/><category term="AVD"/><category term="Security"/><category term="Azure"/><summary type="html"><![CDATA[Configuring strong authentication requirements using Entra Conditional Access to protect access to Azure Virtual Desktop and Windows 365 and understanding the resulting behaviours.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/authn/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/authn/image.jpg"/></entry><entry><title type="html">Automating Nerdio Manager Shell Apps, with Custom apps, Part 3</title><link href="https://stealthpuppy.com/nerdio-shell-apps-p3/" rel="alternate" title="Automating Nerdio Manager Shell Apps, with Custom apps, Part 3" type="text/html"/><published>2025-08-18T04:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/nerdio-shell-apps-p3</id><content type="html" xml:base="https://stealthpuppy.com/nerdio-shell-apps-p3/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#custom-applications" id="markdown-toc-custom-applications">Custom Applications</a></li>
  <li><a href="#example-application" id="markdown-toc-example-application">Example application</a>    <ul>
      <li><a href="#detection-script" id="markdown-toc-detection-script">Detection script</a></li>
      <li><a href="#install-script" id="markdown-toc-install-script">Install script</a></li>
      <li><a href="#uninstall-script" id="markdown-toc-uninstall-script">Uninstall script</a></li>
    </ul>
  </li>
  <li><a href="#preparing-a-package" id="markdown-toc-preparing-a-package">Preparing a package</a></li>
  <li><a href="#summary" id="markdown-toc-summary">Summary</a></li>
</ul>

<p>Now that we have a workflow that uses <a href="https://stealthpuppy.com/nerdio-shell-apps-p1/">Evergreen to find application versions and binaries</a>, and <a href="https://stealthpuppy.com/nerdio-shell-apps-p2/">imports these along with application definitions to create and update Nerdio Manager Shell Apps</a>, let’s take a look at supporting custom applications. These might be legacy applications, in-house custom apps, applications that require manually downloading (i.e. require a login to get to binaries) or perhaps even existing packages in ConfigMgr that you want to import into Shell Apps.</p>

<h2 id="custom-applications">Custom Applications</h2>

<p>As with any Shell App, the application binaries, detection, installation and uninstallation scripts are required. Unlike leverging Evergreen or VcRedist as an automatic source to find the latest binaries and versions, custom application require defining these properties manually.</p>

<p>The pipeline will require at least three things:</p>

<ol>
  <li>Configure the <code class="language-plaintext highlighter-rouge">Source</code> to be <code class="language-plaintext highlighter-rouge">"type": "Static"</code> in the <code class="language-plaintext highlighter-rouge">definition.json</code></li>
  <li>A URL to download the binaries - only a HTTPS source is supported. In a future update, I might support local paths for upload</li>
  <li>A version number of the target application to be used for detection</li>
</ol>

<p>When the application source is updated, the <code class="language-plaintext highlighter-rouge">definition.json</code> file can be modified to reflect the new version, pushed to the repository and the pipeline will import the new version of the application.</p>

<h2 id="example-application">Example application</h2>

<p>Here’s a simple example of a custom application using the Microsoft Configuration Manager Support Center available from the ConfigMgr ISO. This is updated  every so often and requires downloading the updated ISO or extracting the MSI file from a ConfigMgr install.</p>

<p>In the <code class="language-plaintext highlighter-rouge">definition.json</code>, I have specified a URL that is publicly available and have manually determined the application version number from installing the application on a test machine. this is a basic MSI file, so the <a href="https://github.com/aaronparker/nerdio/tree/main/shell-apps/Microsoft/SupportCenter">install script performs a silent install</a>.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Configuration Manager Support Center"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Support Center has powerful capabilities including troubleshooting and real-time log viewing."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"isPublic"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"publisher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"detectScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#detectScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"installScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#installScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"uninstallScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#uninstallScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"fileUnzip"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
    </span><span class="nl">"versions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#version"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"isPreview"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
            </span><span class="nl">"installScriptOverride"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"file"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"sourceUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#sourceUrl"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#sha256"</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Static"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"5.2403.1209.1000"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://stavdghthbdflhzmvc.blob.core.windows.net/binaries/SupportCenterInstaller.msi"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="detection-script">Detection script</h3>

<p>The detection script I’ve written for this, validates that the file <code class="language-plaintext highlighter-rouge">C:\ProgramFiles (x86)\Configuration Manager Support Center\ConfigMgrSupportCenterViewer.exe</code> exists and matches the expected version or is higher. An alternative approach could query for the installed application.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Variables</span><span class="w">
</span><span class="p">[</span><span class="n">System.String</span><span class="p">]</span><span class="w"> </span><span class="nv">$FilePath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">${Env:ProgramFiles(x86)}</span><span class="s2">\Configuration Manager Support Center\ConfigMgrSupportCenterViewer.exe"</span><span class="w">

</span><span class="c"># Detection logic</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">([</span><span class="n">System.String</span><span class="p">]::</span><span class="n">IsNullOrEmpty</span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">TargetVersion</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="c"># This should be an uninstall action</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$FilePath</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$true</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Versions</span><span class="w"> </span><span class="o">-is</span><span class="w"> </span><span class="p">[</span><span class="n">System.Array</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$false</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="c"># This should be an install action, so we need to check the file version</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$FilePath</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"File found: </span><span class="nv">$FilePath</span><span class="s2">"</span><span class="p">)</span><span class="w">
        </span><span class="nv">$FileItem</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ChildItem</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$FilePath</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w">
        </span><span class="nv">$FileInfo</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Diagnostics.FileVersionInfo</span><span class="p">]::</span><span class="n">GetVersionInfo</span><span class="p">(</span><span class="nv">$FileItem</span><span class="o">.</span><span class="nf">FullName</span><span class="p">)</span><span class="w">
        </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"File product version: </span><span class="si">$(</span><span class="nv">$FileInfo</span><span class="o">.</span><span class="nf">ProductVersion</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
        </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Target Shell App version: </span><span class="si">$(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">TargetVersion</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">([</span><span class="n">System.Version</span><span class="p">]::</span><span class="n">Parse</span><span class="p">(</span><span class="nv">$FileInfo</span><span class="o">.</span><span class="nf">ProductVersion</span><span class="p">)</span><span class="w"> </span><span class="o">-ge</span><span class="w"> </span><span class="p">[</span><span class="n">System.Version</span><span class="p">]::</span><span class="n">Parse</span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">TargetVersion</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"No update required. Found '</span><span class="si">$(</span><span class="nv">$FileInfo</span><span class="o">.</span><span class="nf">ProductVersion</span><span class="si">)</span><span class="s2">' against '</span><span class="si">$(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">TargetVersion</span><span class="si">)</span><span class="s2">'."</span><span class="p">)</span><span class="w">
            </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Versions</span><span class="w"> </span><span class="o">-is</span><span class="w"> </span><span class="p">[</span><span class="n">System.Array</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="nv">$FileInfo</span><span class="o">.</span><span class="nf">ProductVersion</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$true</span><span class="w"> </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Update required. Found '</span><span class="si">$(</span><span class="nv">$FileInfo</span><span class="o">.</span><span class="nf">ProductVersion</span><span class="si">)</span><span class="s2">' less than '</span><span class="si">$(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">TargetVersion</span><span class="si">)</span><span class="s2">'."</span><span class="p">)</span><span class="w">
            </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Versions</span><span class="w"> </span><span class="o">-is</span><span class="w"> </span><span class="p">[</span><span class="n">System.Array</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$false</span><span class="w"> </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"File does not exist at: </span><span class="si">$(</span><span class="nv">$FilePath</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Versions</span><span class="w"> </span><span class="o">-is</span><span class="w"> </span><span class="p">[</span><span class="n">System.Array</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="bp">$false</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="install-script">Install script</h3>

<p>The install script performs a simple Windows Installer install - no additional command lines are required for this package.</p>

<p>If this was an existing package with an install script that already exists, this script could be a simple wrapper to call that script. If you were to use a PSADT package and leverage the existing <code class="language-plaintext highlighter-rouge">Invoke-AppDeployToolkit.ps1</code> script, update that script to call the installer with <code class="language-plaintext highlighter-rouge">$Context.GetAttachedBinary()</code>.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Installing package: </span><span class="si">$(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">GetAttachedBinary</span><span class="p">()</span><span class="s2">)"</span><span class="p">)</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">FilePath</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">SystemRoot</span><span class="s2">\System32\msiexec.exe"</span><span class="w">
    </span><span class="nx">ArgumentList</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"/package </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">GetAttachedBinary</span><span class="p">()</span><span class="s2">)</span><span class="se">`"</span><span class="s2"> /quiet"</span><span class="w">
    </span><span class="nx">Wait</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">NoNewWindow</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">PassThru</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">ErrorAction</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="s2">"Stop"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="nv">$result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Start-Process</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Install complete. Return code: </span><span class="si">$(</span><span class="nv">$result</span><span class="o">.</span><span class="nf">ExitCode</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<h3 id="uninstall-script">Uninstall script</h3>

<p>The uninstall script uses a function to dynamically find the MSI product code for this package and then call msiexec to uninstall the package using the discovered code.</p>

<p>If this was an existing package with an uninstall script that already exists, this script could be a simple wrapper to call that script.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Get-InstalledSoftware</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$UninstallKeys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@(</span><span class="w">
        </span><span class="s2">"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"</span><span class="w">
    </span><span class="p">)</span><span class="w">

    </span><span class="nv">$Apps</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@()</span><span class="w">
    </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$Key</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$UninstallKeys</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="kr">try</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nv">$propertyNames</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"DisplayName"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DisplayVersion"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Publisher"</span><span class="p">,</span><span class="w"> </span><span class="s2">"UninstallString"</span><span class="p">,</span><span class="w"> </span><span class="s2">"PSPath"</span><span class="p">,</span><span class="w"> </span><span class="s2">"WindowsInstaller"</span><span class="p">,</span><span class="w"> </span><span class="s2">"InstallDate"</span><span class="p">,</span><span class="w"> </span><span class="s2">"InstallSource"</span><span class="p">,</span><span class="w"> </span><span class="s2">"HelpLink"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Language"</span><span class="p">,</span><span class="w"> </span><span class="s2">"EstimatedSize"</span><span class="p">,</span><span class="w"> </span><span class="s2">"SystemComponent"</span><span class="w">
            </span><span class="nv">$Apps</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">Get-ItemProperty</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Key</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nv">$propertyNames</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="se">`</span><span class="w">
                </span><span class="o">.</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">process</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$null</span><span class="w"> </span><span class="o">-ne</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">DisplayName</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="se">`</span><span class="w">
                </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">SystemComponent</span><span class="w"> </span><span class="o">-ne</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="se">`</span><span class="w">
                </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-Property</span><span class="w"> </span><span class="p">@{</span><span class="nx">n</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Name"</span><span class="p">;</span><span class="w"> </span><span class="nx">e</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">DisplayName</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="p">@{</span><span class="nx">n</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Version"</span><span class="p">;</span><span class="w"> </span><span class="nx">e</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">DisplayVersion</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="s2">"Publisher"</span><span class="p">,</span><span class="w"> </span><span class="s2">"UninstallString"</span><span class="p">,</span><span class="w"> </span><span class="p">@{</span><span class="nx">n</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"RegistryPath"</span><span class="p">;</span><span class="w"> </span><span class="nx">e</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">PSPath</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s2">"Microsoft.PowerShell.Core\\Registry::"</span><span class="p">,</span><span class="w"> </span><span class="s2">""</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="s2">"PSChildName"</span><span class="p">,</span><span class="w"> </span><span class="s2">"WindowsInstaller"</span><span class="p">,</span><span class="w"> </span><span class="s2">"InstallDate"</span><span class="p">,</span><span class="w"> </span><span class="s2">"InstallSource"</span><span class="p">,</span><span class="w"> </span><span class="s2">"HelpLink"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Language"</span><span class="p">,</span><span class="w"> </span><span class="s2">"EstimatedSize"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="se">`</span><span class="w">
                </span><span class="n">Sort-Object</span><span class="w"> </span><span class="nt">-Property</span><span class="w"> </span><span class="s2">"DisplayName"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Publisher"</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="kr">throw</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Exception</span><span class="o">.</span><span class="nf">Message</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="kr">return</span><span class="w"> </span><span class="nv">$Apps</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="n">Get-InstalledSoftware</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Name</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"Configuration Manager Support Center*"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Uninstalling Windows Installer: </span><span class="si">$(</span><span class="bp">$_</span><span class="o">.</span><span class="nf">PSChildName</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
    </span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="nx">FilePath</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">SystemRoot</span><span class="s2">\System32\msiexec.exe"</span><span class="w">
        </span><span class="nx">ArgumentList</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"/uninstall </span><span class="se">`"</span><span class="si">$(</span><span class="bp">$_</span><span class="o">.</span><span class="nf">PSChildName</span><span class="si">)</span><span class="se">`"</span><span class="s2"> /quiet /norestart"</span><span class="w">
        </span><span class="nx">Wait</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
        </span><span class="nx">PassThru</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
        </span><span class="nx">NoNewWindow</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
        </span><span class="nx">ErrorAction</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="s2">"Stop"</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nv">$result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Start-Process</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
    </span><span class="nv">$Context</span><span class="o">.</span><span class="nf">Log</span><span class="p">(</span><span class="s2">"Uninstall complete. Return code: </span><span class="si">$(</span><span class="nv">$result</span><span class="o">.</span><span class="nf">ExitCode</span><span class="si">)</span><span class="s2">"</span><span class="p">)</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="preparing-a-package">Preparing a package</h2>

<p>Packages can come from any source; however for applications with multiple files in the install package, they will need to be first compressed into a single zip file to enable Shell Apps to download the binaries during install. Don’t forget to enable <code class="language-plaintext highlighter-rouge">"fileUnzip": true</code> in the <code class="language-plaintext highlighter-rouge">definition.json</code> file so that the zip file is automatically extracted before running the install script.</p>

<p>This approach should enable you to utilise existing packages that include install and uninstall scripts, including those that might already be leveraging the <a href="https://psappdeploytoolkit.com/">PowerShell App Deployment Toolkit</a>.</p>

<p>Shell Apps will require you to create a new <code class="language-plaintext highlighter-rouge">detect.ps1</code> script to enable detection of the application, but this could be done using the existing metadata from these applications sources (e.g. Configuration Manager detection info, PSASDT detection functions etc.).</p>

<h2 id="summary">Summary</h2>

<p>In this article, I’ve demonstrated how to support custom applications or applications that require manual updates, with our automated pipeline tto create or update Shell Apps in Nerdio Manager.</p>

<p>Using the approaches outlined in this series of articles, we now have a method to automatically update off-the-shell apps with <a href="https://stealthpuppy.com/evergreen">Evergreen</a> and <a href="https://vcredist.com/">VcRedist</a>. Along with a simple approach to adding those manually managed apps, or existing packages, we can use Shell Apps along side existing application delivery mechanisms.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="Automation"/><category term="Deployment"/><summary type="html"><![CDATA[Using Azure Pipelines and the Nerdio Manager REST API to automate import of custom applications.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/></entry><entry><title type="html">Automating Nerdio Manager Shell Apps, with Evergreen, Part 2</title><link href="https://stealthpuppy.com/nerdio-shell-apps-p2/" rel="alternate" title="Automating Nerdio Manager Shell Apps, with Evergreen, Part 2" type="text/html"/><published>2025-07-30T04:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/nerdio-shell-apps-p2</id><content type="html" xml:base="https://stealthpuppy.com/nerdio-shell-apps-p2/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#tools-to-build-a-pipeline" id="markdown-toc-tools-to-build-a-pipeline">Tools to Build a Pipeline</a>    <ul>
      <li><a href="#a-note-on-secure-environments" id="markdown-toc-a-note-on-secure-environments">A note on secure environments</a></li>
    </ul>
  </li>
  <li><a href="#devops-project" id="markdown-toc-devops-project">DevOps Project</a></li>
  <li><a href="#configure-authentication" id="markdown-toc-configure-authentication">Configure Authentication</a></li>
  <li><a href="#configure-permissions" id="markdown-toc-configure-permissions">Configure Permissions</a></li>
  <li><a href="#configure-pipeline-variables" id="markdown-toc-configure-pipeline-variables">Configure Pipeline Variables</a></li>
  <li><a href="#create-the-pipeline" id="markdown-toc-create-the-pipeline">Create the Pipeline</a></li>
  <li><a href="#pipeline-code" id="markdown-toc-pipeline-code">Pipeline Code</a></li>
  <li><a href="#summary" id="markdown-toc-summary">Summary</a></li>
</ul>

<p>In the <a href="https://stealthpuppy.com/nerdio-shell-apps-p1/">previous article</a>, we explored how to automate the creation of Nerdio Manager <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/25499430784909-UAM-Shell-apps-overview-and-usage">Shell Apps</a> with <a href="https://stealthpuppy.com">Evergreen</a>.</p>

<p>Although running a PowerShell script that runs through a list of applications and creates Shell Apps might be fun to watch in an interactive console window, we can take this further and use Azure Pipelines to create a fully automated pipeline. The pipeline can now run on a schedule to import new version of applications or as new application definitions are added to the repository.</p>

<p>In this screenshot, we can see the pipeline running to read the application definition files, find new versions of the application and create or update the Shell Apps as needed.</p>

<p><img src="/media/2025/07/azure-pipeline.jpeg" alt="An Azure Pipeline run that imports Nerdio Manager Shell Apps" /></p>

<p class="figcaption">An Azure Pipeline run that imports Nerdio Manager Shell Apps.</p>

<h2 id="tools-to-build-a-pipeline">Tools to Build a Pipeline</h2>

<p>To create this pipeline, we need to set up a few things:</p>

<ol>
  <li>An Azure DevOps organisation - see <a href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/create-organization?view=azure-devops">Create an organization</a></li>
  <li>An Azure DevOps project with a Git repository - see <a href="https://learn.microsoft.com/en-us/azure/devops/organizations/projects/create-project?view=azure-devops&amp;tabs=browser">Create a project in Azure DevOps</a></li>
  <li>An Azure resource group and storage account - this is used to host the application binaries in blob storage and we need to assign permissions to enable the pipeline to upload files</li>
  <li>An Azure managed identity - this will be used by Azure Pipelines to securely authenticate to the target storage account.  See <a href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview">What are managed identities for Azure resources?</a></li>
</ol>

<p>In this article, I’m not going to run through the creation of these resources in detail, instead I am assuming you are familiar with these services and may have configured them in your environment already.</p>

<h3 id="a-note-on-secure-environments">A note on secure environments</h3>

<p>The pipeline covered in this article, assumes that you will use <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/hosted">Microsoft-hosted Azure Pipelines agents</a>, which will require the target storage account to be publicly accessible. If you have requirements to only access the storage account over private endpoints, you can use <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/linux-agent">self-hosted Azure Pipelines agents</a> that run in an Azure virtual network that has direct access to the storage account.</p>

<p>Additionally, if you also have restrictions on internet access, the Evergreen API can be used to <a href="https://stealthpuppy.com/evergreen/endpoints/">list the required endpoints</a> to detect and download application binaries.</p>

<h2 id="devops-project">DevOps Project</h2>

<p>After you have created an Azure DevOps project with a Git repository, you’ll need to add several files and an expected directory structure:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pipeline.yml</code> - this is the Azure Pipeline that defines how the pipeline should execute and import Shell Apps</li>
  <li><code class="language-plaintext highlighter-rouge">NerdioShellApps.psm1</code> - a module with functions required for automating the import of Shell Apps</li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">apps</code> - a directory that contains Shell App definitions with a directory per-application with the following files:</p>

    <ul>
      <li><strong>Definition.json</strong> - includes a definition of the Shell App required during import. This file also includes logic that tells Evergreen how to find the application version and binaries</li>
      <li><strong>Detect.ps1</strong> - is used in the Shell App to detect the installed application</li>
      <li><strong>Install.ps1</strong> - installs the Shell App</li>
      <li><strong>Uninstall.ps1</strong> - uninstalls the Shell App</li>
    </ul>
  </li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">apps</code> directory can be organised how you like, for example, applications can be organised as sub-directories in a directory for each application vendor, but this is not a hard requirement. Just ensure that each application is organised in its own directory.</p>

<p><img src="/media/2025/07/azure-repo.jpeg" alt="An Azure DevOps project repository showing the list of files in the repo" /></p>

<p class="figcaption">An example Azure DevOps project repository with the expected directory and file structure.</p>

<p>We will look at the pipeline in more detail later, but managing the application definitions in a Git repository allows you to use version control for the files, manage the code as maturely as your processes allow, and for the pipeline to trigger when new applications are added to the repository.</p>

<h2 id="configure-authentication">Configure Authentication</h2>

<p>To allow the pipeline to upload application binaries to the target storage account, we need to configure a <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops">service connection</a>. This will use the Azure managed identity</p>

<p><img src="/media/2025/07/azure-service-connection.jpeg" alt="Creating an Azure Pipelines service connection using a managed identity" /></p>

<p class="figcaption">Creating an Azure Pipelines service connection using a managed identity.</p>

<ol>
  <li>Create a new service connection and select <strong>Azure Resource Manager</strong></li>
  <li>Select the subscription, resource group and managed identity</li>
  <li>Select the scope for the service connection - subscription or management group</li>
  <li>Select the resource group for the service connection - this is optional, but useful for scoping the connection to the resource group that contains the target storage account</li>
  <li>Give the service connection a name and save to create the service connection. The pipeline will need to be updated with the name of the service connection under <code class="language-plaintext highlighter-rouge">Variables / service</code></li>
</ol>

<h2 id="configure-permissions">Configure Permissions</h2>

<p>After creating the service connection, don’t forget to assign the <strong>Storage Blob Data Contributor</strong> role to the managed identity on the target storage account.</p>

<p>The screenshot below shows the managed identity with the Contributor inherited from the resource group and with the Storage Blob Data Contributor role directly on the storage account. Either approach will work; however, it is best to assign the most finely grained permission to the managed identity as you can. You may also want to dedicate a storage account to hosting application binaries so the managed identity only has access to that storage account and no others.</p>

<p><img src="/media/2025/07/azure-iam.jpeg" alt="Assigning the 'Storage Blob Data Contributor' role on the storage account to the managed identity" /></p>

<p class="figcaption">Assign the ‘Storage Blob Data Contributor’ role on the storage account to the managed identity.</p>

<h2 id="configure-pipeline-variables">Configure Pipeline Variables</h2>

<p>The pipeline requires variables to be passed into during execution. These should be stored in a variable group named <strong>Credential</strong> in in <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/library/?view=azure-devops">Asset library</a>. These variables can be stored directly in the library or be linked from an Azure Key Vault.</p>

<ul>
  <li><strong>TenantId</strong> - the Entra ID tenant</li>
  <li><strong>ClientId</strong> - the app registration client ID specified in Nerdio Manager (Settings / Environment / Integrations / REST API)</li>
  <li><strong>ClientSecret</strong> - the app registration client secret specified in Nerdio Manager (Settings / Environment / Integrations / REST API). Ensure this variable is configured as secret to protect its value</li>
  <li><strong>ApiScope</strong> - the API scope specified in Nerdio Manager (Settings / Environment / Integrations / REST API)</li>
  <li><strong>OAuthToken</strong> - the OAuthToken specified in Nerdio Manager (Settings / Environment / Integrations / REST API)</li>
  <li><strong>NmeHost</strong> - the Nerdio Manager host name (in the format <code class="language-plaintext highlighter-rouge">nmw-app-s6uhdllx6esom.azurewebsites.net</code>)</li>
  <li><strong>SubscriptionId</strong> - the Azure subscription that hosts the target storage account</li>
  <li><strong>ResourceGroupName</strong> - the Azure resource group that hosts the target storage account</li>
  <li><strong>StorageAccountName</strong> - the target storage account that will host application binaries</li>
  <li><strong>ContainerName</strong> - the blob container name on the target storage account</li>
</ul>

<p><img src="/media/2025/07/devops-library-secrets.jpeg" alt="Credential variables stored in a DevOps asset library" /></p>

<p class="figcaption">Credential variables stored in a DevOps asset library.</p>

<p>After creating the pipeline, enable access the variable group by authorising the pipeline: <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/library/variable-groups?view=azure-devops&amp;tabs=azure-pipelines-ui%2Cyaml#use-variable-groups-in-pipelines">Use variable groups in pipelines</a></p>

<h2 id="create-the-pipeline">Create the Pipeline</h2>

<p>With the code committed to the repository and resources configured, create the pipeline:</p>

<ol>
  <li>Select <strong>Pipelines</strong></li>
  <li>Click <strong>New Pipeline</strong></li>
  <li>Select <strong>Azure Repos Git</strong></li>
  <li>Select the repository</li>
  <li>Choose <strong>Existing Azure Pipelines YAML</strong></li>
  <li>Select the ‘main’ branch and then <code class="language-plaintext highlighter-rouge">/pipeline.yml</code> in the path</li>
  <li>Review and save the pipeline</li>
</ol>

<p>The pipeline should now be ready to execute and import Shell Apps into Nerdio Manager.</p>

<h2 id="pipeline-code">Pipeline Code</h2>

<p>The pipeline code is listed below and is available <a href="https://github.com/aaronparker/nerdio/tree/main/shell-apps">here</a>. The pipeline essentially does the following:</p>

<ul>
  <li>Run when new or modified application definitions are added to the <code class="language-plaintext highlighter-rouge">apps</code> directory in the <code class="language-plaintext highlighter-rouge">main</code> branch</li>
  <li>Run every 24 hours to update existing Shell Apps with new application versions</li>
  <li>It queries for existing Shell Apps to determine whether the app already exists</li>
  <li>If the Shell App does exist, it then determines whether a new version is available before updating the existing Shell App with a new version</li>
  <li>Old version of Shell Apps will be pruned to ensure only 3 version exist (change this number to keep more versions)</li>
  <li>Finally, the list of Shell Apps in Nerdio Manager will be displayed, along with the lasted version of each Shell App</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Automate the import of Nerdio Manager Shell Apps with Evergreen</span>

<span class="c1"># Trigger the pipeline on change to the 'apps' directory</span>
<span class="na">trigger</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
        <span class="na">include</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">main</span> <span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
        <span class="na">include</span><span class="pi">:</span> <span class="pi">[</span> <span class="s2">"</span><span class="s">apps/**"</span> <span class="pi">]</span>

<span class="c1"># Also run the pipeline on a schedule to update new versions of apps</span>
<span class="na">schedules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0</span><span class="nv"> </span><span class="s">17</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*"</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Daily 2AM Run (AEST)</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="na">include</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">main</span>
    <span class="na">always</span><span class="pi">:</span> <span class="kc">true</span>

<span class="c1"># Run the pipeline on an Ubuntu runner (and in PowerShell 7)</span>
<span class="na">pool</span><span class="pi">:</span>
  <span class="na">vmImage</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>

<span class="c1"># Variables - the credentials group and the service connection name</span>
<span class="na">variables</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">group</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Credentials'</span> <span class="c1"># Update to match your environment</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">service</span>
  <span class="na">value</span><span class="pi">:</span> <span class="s1">'</span><span class="s">sc-rg-Avd1Images-aue'</span> <span class="c1"># Update to match your environment</span>

<span class="na">jobs</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">Import</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Import</span><span class="nv"> </span><span class="s">Nerdio</span><span class="nv"> </span><span class="s">Shell</span><span class="nv"> </span><span class="s">Apps'</span>

  <span class="na">steps</span><span class="pi">:</span>
  <span class="c1"># Checkout the repository so we have access to the module and app definitions</span>
  <span class="pi">-</span> <span class="na">checkout</span><span class="pi">:</span> <span class="s">self</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Checkout</span><span class="nv"> </span><span class="s">repository'</span>

  <span class="c1"># Install the required PowerShell modules</span>
  <span class="pi">-</span> <span class="na">pwsh</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">Install-Module -Name "Evergreen", "VcRedist" -AllowClobber -Force -Scope CurrentUser</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">modules</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Install</span><span class="nv"> </span><span class="s">Modules'</span>
    <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
    <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">stop</span>

  <span class="c1"># Validate connection to Azure using the service connection</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzurePowerShell@5</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">auth</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Azure</span><span class="nv"> </span><span class="s">Login'</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(service)'</span>
      <span class="na">ScriptType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">InlineScript'</span>
      <span class="na">Inline</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">Write-Host "Authenticated to Azure using service connection: $(service)"</span>
        <span class="s">Set-AzContext -SubscriptionId $(SubscriptionId) -TenantId $(TenantId)</span>
      <span class="na">azurePowerShellVersion</span><span class="pi">:</span> <span class="s1">'</span><span class="s">LatestVersion'</span>
      <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">stop</span>
      <span class="na">pwsh</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>

  <span class="c1"># Authenticate to Nerdio Manager, set the Azure context, and import the shell apps</span>
  <span class="c1"># This code checks whether the app already exists before importing or updating it</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzurePowerShell@5</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">import</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Import</span><span class="nv"> </span><span class="s">Shell</span><span class="nv"> </span><span class="s">Apps'</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(service)'</span>
      <span class="na">ScriptType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">InlineScript'</span>
      <span class="na">Inline</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">$InformationPreference = "Continue"</span>
        <span class="s">Import-Module -Name "./NerdioShellApps.psm1" -Force</span>
        <span class="s">Set-AzContext -SubscriptionId $(SubscriptionId) -TenantId $(TenantId)</span>
        <span class="s">$params = @{</span>
            <span class="s">ClientId           = "$(ClientId)"</span>
            <span class="s">ClientSecret       = "$(ClientSecret)"</span>
            <span class="s">TenantId           = "$(TenantId)"</span>
            <span class="s">ApiScope           = "$(ApiScope)"</span>
            <span class="s">SubscriptionId     = "$(SubscriptionId)"</span>
            <span class="s">OAuthToken         = "$(OAuthToken)"</span>
            <span class="s">ResourceGroupName  = "$(resourceGroupName)"</span>
            <span class="s">StorageAccountName = "$(storageAccountName)"</span>
            <span class="s">ContainerName      = "$(containerName)"</span>
            <span class="s">NmeHost            = "$(nmeHost)"</span>
        <span class="s">}</span>
        <span class="s">Set-NmeCredentials @params</span>
        <span class="s">Connect-Nme</span>
        <span class="s">$Path = Join-Path -Path $(build.sourcesDirectory) -ChildPath "apps"</span>
        <span class="s">$Paths = Get-ChildItem -Path $Path -Include "Definition.json" -Recurse | ForEach-Object { $_ | Select-Object -ExpandProperty "DirectoryName" }</span>
        <span class="s">foreach ($Path in $Paths) {</span>
            <span class="s">$Def = Get-ShellAppDefinition -Path $Path</span>
            <span class="s">$App = Get-AppMetadata -Definition $Def</span>
            <span class="s">$ShellApp = Get-ShellApp | ForEach-Object {</span>
                <span class="s">$_ | Where-Object { $_.name -eq $Def.name }</span>
            <span class="s">}</span>
            <span class="s">if ($null -eq $ShellApp) {</span>
                <span class="s">Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Importing: $($Def.name)"</span>
                <span class="s">$NewApp = New-ShellApp -Definition $Def -AppMetadata $App</span>
                <span class="s">$NewApp.job.status</span>
            <span class="s">}</span>
            <span class="s">else {</span>
                <span class="s">Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Updating Shell App: $($Def.name)"</span>
                <span class="s">$UpdateApp = Update-ShellApp -Id $ShellApp.Id -Definition $Def</span>
                <span class="s">$UpdateApp.job.status</span>
                <span class="s">$ExistingVersions = Get-ShellAppVersion -Id $ShellApp.Id | ForEach-Object {</span>
                    <span class="s">$_ | Where-Object { $_.name -eq $App.Version }</span>
                <span class="s">}</span>
                <span class="s">if ($null -eq $ExistingVersions -or [System.Version]$ExistingVersions.name -lt [System.Version]$App.Version) {</span>
                    <span class="s">$NewAppVersion = New-ShellAppVersion -Id $ShellApp.Id -AppMetadata $App</span>
                    <span class="s">$NewAppVersion.job.status</span>
                <span class="s">}</span>
                <span class="s">else {</span>
                    <span class="s">Write-Information -MessageData "$($PSStyle.Foreground.Yellow)Shell app version exists: '$($Def.name) $($App.Version)'. No action taken."</span>
                <span class="s">}</span>
            <span class="s">}</span>
        <span class="s">}</span>
        <span class="s">Remove-NerdioManagerSecretsFromMemory</span>
      <span class="na">azurePowerShellVersion</span><span class="pi">:</span> <span class="s1">'</span><span class="s">LatestVersion'</span>
      <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">stop</span>
      <span class="na">pwsh</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>

  <span class="c1"># Prune Shell Apps versions</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzurePowerShell@5</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">prune</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Prune</span><span class="nv"> </span><span class="s">Shell</span><span class="nv"> </span><span class="s">Apps</span><span class="nv"> </span><span class="s">versions'</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(service)'</span>
      <span class="na">ScriptType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">InlineScript'</span>
      <span class="na">Inline</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">$InformationPreference = "Continue"</span>
        <span class="s">Import-Module -Name "Az.Accounts", "Az.Storage", "Evergreen", "VcRedist" -Force</span>
        <span class="s">Import-Module -Name "./NerdioShellApps.psm1" -Force</span>
        <span class="s">Set-AzContext -SubscriptionId $(SubscriptionId) -TenantId $(TenantId)</span>
        <span class="s">$params = @{</span>
            <span class="s">ClientId           = "$(ClientId)"</span>
            <span class="s">ClientSecret       = "$(ClientSecret)"</span>
            <span class="s">TenantId           = "$(TenantId)"</span>
            <span class="s">ApiScope           = "$(ApiScope)"</span>
            <span class="s">SubscriptionId     = "$(SubscriptionId)"</span>
            <span class="s">OAuthToken         = "$(OAuthToken)"</span>
            <span class="s">ResourceGroupName  = "$(resourceGroupName)"</span>
            <span class="s">StorageAccountName = "$(storageAccountName)"</span>
            <span class="s">ContainerName      = "$(containerName)"</span>
            <span class="s">NmeHost            = "$(nmeHost)"</span>
        <span class="s">}</span>
        <span class="s">Set-NmeCredentials @params</span>
        <span class="s">Connect-Nme</span>
        <span class="s">$KeepCount = 3</span>
        <span class="s">Get-ShellApp | ForEach-Object {</span>
            <span class="s">$ExistingVersions = Get-ShellAppVersion -Id $_.id | `</span>
                <span class="s">Where-Object { $_.isPreview -eq $false } | `</span>
                <span class="s">Sort-Object -Property @{ Expression = { [System.Version]$_.Version }; Descending = $true }</span>
            <span class="s">if ($ExistingVersions.Count -gt $KeepCount) {</span>
                <span class="s">$VersionsToRemove = $ExistingVersions | Select-Object -Skip ($ExistingVersions.Count - $KeepCount)</span>
                <span class="s">foreach ($Version in $VersionsToRemove) {</span>
                    <span class="s">Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Removing Shell App Version: $($_.id) $($Version.name)"</span>
                    <span class="s">$Result = Remove-ShellAppVersion -Id $_.id -Name $Version.name -Confirm:$false</span>
                    <span class="s">$Result.job.status</span>
                    <span class="s">if ($Result.job.status -eq "Completed") {</span>
                        <span class="s">$File = $Version.file.sourceUrl -split "\?"</span>
                        <span class="s">$FileName = $File -split "/" | Select-Object -Last 1</span>
                        <span class="s">Remove-AzStorageBlob -Container $(containerName) -Blob $FileName -Confirm:$false</span>
                    <span class="s">}</span>
                <span class="s">}</span>
            <span class="s">}</span>
            <span class="s">else {</span>
                <span class="s">Write-Information -MessageData "$($PSStyle.Foreground.Yellow)No versions to remove for Shell App: $($_.id)"</span>
            <span class="s">}</span>
        <span class="s">}</span>
        <span class="s">Remove-NerdioManagerSecretsFromMemory</span>
      <span class="na">azurePowerShellVersion</span><span class="pi">:</span> <span class="s1">'</span><span class="s">LatestVersion'</span>
      <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">stop</span>
      <span class="na">pwsh</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>

  <span class="c1"># List the Shell Apps in Nerdio Manager</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzurePowerShell@5</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">list</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">List</span><span class="nv"> </span><span class="s">Shell</span><span class="nv"> </span><span class="s">Apps'</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(service)'</span>
      <span class="na">ScriptType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">InlineScript'</span>
      <span class="na">Inline</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">Import-Module -Name "./NerdioShellApps.psm1" -Force</span>
        <span class="s">$params = @{</span>
            <span class="s">ClientId           = "$(ClientId)"</span>
            <span class="s">ClientSecret       = "$(ClientSecret)"</span>
            <span class="s">TenantId           = "$(TenantId)"</span>
            <span class="s">ApiScope           = "$(ApiScope)"</span>
            <span class="s">SubscriptionId     = "$(SubscriptionId)"</span>
            <span class="s">OAuthToken         = "$(OAuthToken)"</span>
            <span class="s">ResourceGroupName  = "$(resourceGroupName)"</span>
            <span class="s">StorageAccountName = "$(storageAccountName)"</span>
            <span class="s">ContainerName      = "$(containerName)"</span>
            <span class="s">NmeHost            = "$(nmeHost)"</span>
        <span class="s">}</span>
        <span class="s">Set-NmeCredentials @params</span>
        <span class="s">Connect-Nme</span>
        <span class="s">Get-ShellApp | ForEach-Object {</span>
            <span class="s">$ExistingVersions = Get-ShellAppVersion -Id $_.id | `</span>
                <span class="s">Where-Object { $_.isPreview -eq $false } | `</span>
                <span class="s">Sort-Object -Property @{ Expression = { [System.Version]$_.Version }; Descending = $true }</span>
            <span class="s">[PSCustomObject]@{</span>
                <span class="s">publisher     = $_.publisher</span>
                <span class="s">name          = $_.name</span>
                <span class="s">versionCount  = $ExistingVersions | Measure-Object | Select-Object -ExpandProperty "Count"</span>
                <span class="s">latestVersion = ($ExistingVersions | Select-Object -First 1).name</span>
                <span class="s">createdAt     = $_.createdAt</span>
                <span class="s">fileUnzip      = $_.fileUnzip</span>
                <span class="s">isPublic      = $_.isPublic</span>
                <span class="s">id            = $_.id</span>
            <span class="s">}</span>
        <span class="s">} | Format-Table -AutoSize</span>
        <span class="s">Remove-NerdioManagerSecretsFromMemory</span>
      <span class="na">azurePowerShellVersion</span><span class="pi">:</span> <span class="s1">'</span><span class="s">LatestVersion'</span>
      <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">stop</span>
      <span class="na">pwsh</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
</code></pre></div></div>

<h2 id="summary">Summary</h2>

<p>In this article, I’ve demonstrated how to create an automated pipeline that will continuously run to create or update Shell Apps in Nerdio Manager. Leveraging Azure Pipelines enables you to manage the Shell Apps creation pipeline as a completely automated solution and saves Nerdio Manager administrators many hours of valuable time.</p>

<p>Using Evergreen as a source for discovery of application version and installers, enables the deployment of Shell Apps from <a href="https://stealthpuppy.com/apptracker/">a library of 374 applications and 6712 unique application installers</a>. This list covers most of the off the shelf applications typically used in Windows desktop environments.</p>

<p>In the next part of this article series, I’ll cover how to use this framework to import other applications, not supported by Evergreen, into Nerdio Manager Shell Apps.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="Automation"/><category term="Azure"/><summary type="html"><![CDATA[Using Azure Pipelines and Evergreen for hands off creation of Shell Apps in Nerdio Manager.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/></entry><entry><title type="html">Automating Nerdio Manager Shell Apps, with Evergreen, Part 1</title><link href="https://stealthpuppy.com/nerdio-shell-apps-p1/" rel="alternate" title="Automating Nerdio Manager Shell Apps, with Evergreen, Part 1" type="text/html"/><published>2025-07-29T06:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/nerdio-shell-apps</id><content type="html" xml:base="https://stealthpuppy.com/nerdio-shell-apps-p1/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#winget-vs-shell-apps--evergreen" id="markdown-toc-winget-vs-shell-apps--evergreen">Winget vs. Shell Apps + Evergreen</a></li>
  <li><a href="#powershell-module" id="markdown-toc-powershell-module">PowerShell Module</a></li>
  <li><a href="#application-definitions" id="markdown-toc-application-definitions">Application Definitions</a></li>
  <li><a href="#importing-a-shell-app" id="markdown-toc-importing-a-shell-app">Importing a Shell App</a>    <ul>
      <li><a href="#create-a-storage-account" id="markdown-toc-create-a-storage-account">Create a storage account</a></li>
      <li><a href="#import-the-module" id="markdown-toc-import-the-module">Import the module</a></li>
      <li><a href="#authentication" id="markdown-toc-authentication">Authentication</a></li>
      <li><a href="#read-the-application-definition" id="markdown-toc-read-the-application-definition">Read the application definition</a></li>
      <li><a href="#find-the-application-details" id="markdown-toc-find-the-application-details">Find the application details</a></li>
      <li><a href="#create-the-shell-app" id="markdown-toc-create-the-shell-app">Create the Shell App</a></li>
      <li><a href="#update-the-shell-app-with-a-new-version" id="markdown-toc-update-the-shell-app-with-a-new-version">Update the Shell App with a new version</a></li>
    </ul>
  </li>
  <li><a href="#summary" id="markdown-toc-summary">Summary</a></li>
</ul>

<p><a href="https://nmehelp.getnerdio.com/hc/en-us/articles/19837802929677-Release-Notes#h_01JZTAWKX07A7TWT0PX8P58G02">Nerdio Manager for Enterprise 7.2</a> introduces API endpoints for managing <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/25499430784909-UAM-Shell-apps-overview-and-usage">Shell Apps</a>. This provides an exciting opportunity for automating Shell Apps management for a repeatable and structured method for creating and updating Shell Apps. Even better, we can integrate this approach with <a href="https://stealthpuppy.com/evergreen">Evergreen</a> for automatic discovery of new application binaries.</p>

<h2 id="winget-vs-shell-apps--evergreen">Winget vs. Shell Apps + Evergreen</h2>

<p>Nerdio Manager supports deployment of applications via Winget with <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/26124323091981-UAM-Supported-configurations">Unified Application Management</a> supporting the public Winget repository or a private repository.</p>

<p>There’s an inevitable comparison then between using Winget or Shell Apps + Evergreen to deploy applications. Winget is certainly the simpler approach and supports a wide range of applications, but relies on application deployment from the internet. Nerdio Manager can create private Winget repositories to keep application deployment within the customer tenant; however, private repositories require several Azure resources including a Cosmos database.</p>

<p>Shell Apps with Evergreen requires just an Azure storage account, keeping application binaries within the customer tenant while using the simplest architecture possible. Additionally, using Evergreen provides you with clear visibility into and auditing of application discovery and download, all within your environment.</p>

<p>Using Evergreen as a source for discovery of application version and installers, enables the deployment of Shell Apps from <a href="https://stealthpuppy.com/apptracker/">a library of 374 applications and 6712 unique application installers</a>.</p>

<h2 id="powershell-module">PowerShell Module</h2>

<p>To create this integration, I’ve created a custom PowerShell module - the official <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/26124355338893-PowerShell-Module-Tutorial">Nerdio Manager PowerShell module</a> will be updated in the future and may replace some of the functions in this custom module.</p>

<p>The custom module is hosted in my <a href="https://github.com/aaronparker/nerdio/tree/main/shell-apps">nerdio</a> repository on GitHub -  see <code class="language-plaintext highlighter-rouge">NerdioShellApps.psm1</code>. The repository also includes files for a set of supported applications that can be imported into Shell Apps.</p>

<p><a href="https://github.com/aaronparker/nerdio/blob/main/shell-apps/Create-ShellApps.ps1">Create-ShellApps.ps1</a> demonstrates how to use the module to import applications into Nerdio Manager Shell Apps.</p>

<p>These support modules are also required: Az.Accounts, Az.Storage, Evergreen.</p>

<h2 id="application-definitions">Application Definitions</h2>

<p>Several files are required for defining a Shell App. The module expects a directory for each application with the following files:</p>

<ul>
  <li><strong>Definition.json</strong> - includes a definition of the Shell App required during import. This file also includes logic that tells Evergreen how to find the application version and binaries</li>
  <li><strong>Detect.ps1</strong> - is used in the Shell App to detect the installed application</li>
  <li><strong>Install.ps1</strong> - installs the Shell App</li>
  <li><strong>Uninstall.ps1</strong> - uninstalls the Shell App</li>
</ul>

<p>For details on the detect, install and uninstall scripts, see this article: <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/32612189461261-UAM-Shell-apps-technical-reference-guide">Shell apps technical reference guide</a>.</p>

<p>Here’s a look at an example <code class="language-plaintext highlighter-rouge">Definition.json</code> - this example defines Microsoft Visual Studio Code, including placeholder values that will be replaced later, and values that Evergreen will use to find the 64-bit version of the Stable release of Visual Studio Code.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Visual Studio Code"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Visual Studio Code is a code editor redefined and optimized for building and debugging modern web and cloud applications."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"isPublic"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"publisher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"detectScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#detectScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"installScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#installScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"uninstallScript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#uninstallScript"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"fileUnzip"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
    </span><span class="nl">"versions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#version"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"isPreview"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
            </span><span class="nl">"installScriptOverride"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"file"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"sourceUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#sourceUrl"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#sha256"</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Evergreen"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"app"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MicrosoftVisualStudioCode"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"$_.Architecture -eq </span><span class="se">\"</span><span class="s2">x64</span><span class="se">\"</span><span class="s2"> -and $_.Channel -eq </span><span class="se">\"</span><span class="s2">Stable</span><span class="se">\"</span><span class="s2"> -and $_.Platform -eq </span><span class="se">\"</span><span class="s2">win32-x64</span><span class="se">\"</span><span class="s2">"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="importing-a-shell-app">Importing a Shell App</h2>

<p>To import a Shell App with the module and an application definition, this high-level workflow is followed:</p>

<ol>
  <li>Create a storage account and blob container</li>
  <li>Import the module</li>
  <li>Authenticate to Azure and Nerdio Manager</li>
  <li>Read the application definition</li>
  <li>Find the latest application version and binary with Evergreen</li>
  <li>Create the Shell App, or add a new version to an existing Shell App</li>
</ol>

<h3 id="create-a-storage-account">Create a storage account</h3>

<p>We need an Azure storage account to store application binaries. We just need a standard tier storage account to host blob storage and a blob container to upload files to. The container can be configured for Private access, because we configure a SAS token for each file hosted in that container.</p>

<p><img src="/media/2025/07/storage-account.jpeg" alt="Azure storage account blob storage" /></p>

<p class="figcaption">Azure storage account blob storage with Private access configured.</p>

<h3 id="import-the-module">Import the module</h3>

<p>This step is simple enough, save the <code class="language-plaintext highlighter-rouge">NerdioShellApps.psm1</code> file locally and import with:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">PS</span><span class="w"> </span><span class="err">&gt;</span><span class="w"> </span><span class="nx">Import-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">".\NerdioShellApps.psm1"</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span></code></pre></div></div>

<h3 id="authentication">Authentication</h3>

<p>Authentication to Azure is required - you will need to authenticate with an account that has at least the <a href="https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-data-contributor">Storage Blob Data Contributor</a> role. When run manually, use the <code class="language-plaintext highlighter-rouge">Connect-AzAccount</code> cmdlet to authenticate.</p>

<p>Authentication to Nerdio Manager is similar to <code class="language-plaintext highlighter-rouge">Connect-Nme</code> in the official <a href="https://nmehelp.getnerdio.com/hc/en-us/articles/26124355338893-PowerShell-Module-Tutorial">Nerdio Manager PowerShell module</a>; however, we also need to authenticate to the target Azure subscription to upload files to a storage account.</p>

<p>The following credentials, secrets and values are required - in my lab environment I have these saved in JSON files that my script reads when executed; however, these would be best stored securely - for example, in an Azure Key Vault:</p>

<ul>
  <li><strong>ClientId</strong> - Id of the Entra ID app registration configured with the Nerdio Manager REST API</li>
  <li><strong>ClientSecret</strong> - Secret used to authenticate with the app registration</li>
  <li><strong>TenantId</strong> - Entra ID tenant Id</li>
  <li><strong>ApiScope</strong> - API scope provided by the Nerdio Manager REST API</li>
  <li><strong>OAuthToken</strong> - OAuth token  provided by the Nerdio Manager REST API</li>
  <li><strong>NmeHost</strong> - Nerdio Manager hostname</li>
  <li><strong>SubscriptionId</strong> - Azure subscription Id</li>
  <li><strong>ResourceGroupName</strong> - Azure resource group that contains the storage account</li>
  <li><strong>StorageAccountName</strong> - Azure storage account used to host application binaries</li>
  <li><strong>ContainerName</strong> - Azure storage account blob container where application binaries will be uploaded to</li>
</ul>

<p>Because I’m storing these credentials locally, authentication looks like the code below where I’m reading the stored values from JSON files and passing them to <code class="language-plaintext highlighter-rouge">Set-NmeCredentials</code>. This function stores the credentials, secrets and values for use later with other functions.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$EnvironmentFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"/Users/aaron/projects/nerdio/api/environment.json"</span><span class="w">
</span><span class="nv">$CredentialsFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"/Users/aaron/projects/nerdio/api/creds.json"</span><span class="w">
</span><span class="nv">$Env</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$EnvironmentFile</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$Creds</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$CredentialsFile</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">ClientId</span><span class="w">           </span><span class="o">=</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">ClientId</span><span class="w">
    </span><span class="nx">ClientSecret</span><span class="w">       </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">ConvertTo</span><span class="err">-</span><span class="nx">SecureString</span><span class="w"> </span><span class="err">-</span><span class="nx">String</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">ClientSecret</span><span class="w"> </span><span class="err">-</span><span class="nx">AsPlainText</span><span class="w"> </span><span class="err">-</span><span class="nx">Force</span><span class="err">)</span><span class="w">
    </span><span class="nx">TenantId</span><span class="w">           </span><span class="o">=</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">TenantId</span><span class="w">
    </span><span class="nx">ApiScope</span><span class="w">           </span><span class="o">=</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">ApiScope</span><span class="w">
    </span><span class="nx">SubscriptionId</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">SubscriptionId</span><span class="w">
    </span><span class="nx">OAuthToken</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="nv">$Creds</span><span class="err">.</span><span class="nx">OAuthToken</span><span class="w">
    </span><span class="nx">ResourceGroupName</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="nv">$Env</span><span class="err">.</span><span class="nx">resourceGroupName</span><span class="w">
    </span><span class="nx">StorageAccountName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$Env</span><span class="err">.</span><span class="nx">storageAccountName</span><span class="w">
    </span><span class="nx">ContainerName</span><span class="w">      </span><span class="o">=</span><span class="w"> </span><span class="nv">$Env</span><span class="err">.</span><span class="nx">containerName</span><span class="w">
    </span><span class="nx">NmeHost</span><span class="w">            </span><span class="o">=</span><span class="w"> </span><span class="nv">$Env</span><span class="err">.</span><span class="nx">nmeHost</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">PS</span><span class="w"> </span><span class="err">&gt;</span><span class="w"> </span><span class="nx">Set-NmeCredentials</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>

<p>To authenticate to Nerdio Manager use the Connect-Nme function. Note that this function name clashes with the official Nerdio Manager PowerShell module, so configure your environment appropriately if you have both modules installed:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">PS</span><span class="w"> </span><span class="err">&gt;</span><span class="w"> </span><span class="nx">Connect-Nme</span><span class="w">
</span><span class="n">Authenticated</span><span class="w"> </span><span class="nx">to</span><span class="w"> </span><span class="nx">Nerdio</span><span class="w"> </span><span class="nx">Manager.</span><span class="w">
</span><span class="n">Token</span><span class="w"> </span><span class="nx">expires:</span><span class="w"> </span><span class="nx">29/7/2025</span><span class="w"> </span><span class="nx">5:17:18</span><span class="err"> </span><span class="nx">pm</span><span class="w">
</span></code></pre></div></div>

<h3 id="read-the-application-definition">Read the application definition</h3>

<p>Our first step after authentication is to read the application definition. <code class="language-plaintext highlighter-rouge">Get-ShellAppDefinition</code> accepts a path to a directory that contains the <code class="language-plaintext highlighter-rouge">Definition.json</code>, <code class="language-plaintext highlighter-rouge">Detect.ps</code>, <code class="language-plaintext highlighter-rouge">Install.ps1</code>, and <code class="language-plaintext highlighter-rouge">Uninstall.ps1</code> files, and returns a single object which is the application definition (still with some placeholder values).</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"/Users/aaron/projects/nerdio/shell-apps/Microsoft/VisualStudioCode"</span><span class="w">
</span><span class="nv">$Def</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ShellAppDefinition</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Path</span><span class="w">
</span></code></pre></div></div>

<h3 id="find-the-application-details">Find the application details</h3>

<p>The application definition should have details that Evergreen will use find the application version and download URL:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$App</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-EvergreenAppDetail</span><span class="w"> </span><span class="nt">-Definition</span><span class="w"> </span><span class="nv">$Def</span><span class="w">
</span></code></pre></div></div>

<p>Be sure to test that this function returns a single object only. In this example, we have  an object that describes the 64-bit, Stable channel version of Visual Studio Code:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Version</span><span class="w">      </span><span class="p">:</span><span class="w"> </span><span class="nx">1.102.2</span><span class="w">
</span><span class="n">Platform</span><span class="w">     </span><span class="p">:</span><span class="w"> </span><span class="nx">win32-x64</span><span class="w">
</span><span class="n">Channel</span><span class="w">      </span><span class="p">:</span><span class="w"> </span><span class="nx">Stable</span><span class="w">
</span><span class="n">Architecture</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="nx">x64</span><span class="w">
</span><span class="n">Sha256</span><span class="w">       </span><span class="p">:</span><span class="w"> </span><span class="nx">cfd0ce29f75313601ae5cd905c7cd12e4b2b759badfc2c1c9ec1691fa82a2060</span><span class="w">
</span><span class="n">URI</span><span class="w">          </span><span class="p">:</span><span class="w"> </span><span class="nx">https://vscode.download.prss.microsoft.com/dbazure/download/stable/c306e94f98122556ca081f527b466015e1bc37b0/VSCodeSetup-x64-1.102.2.exe</span><span class="w">
</span></code></pre></div></div>

<h3 id="create-the-shell-app">Create the Shell App</h3>

<p>Now that we have authenticated to the target environment and have the details requires to create the Shell App, we should first check whether the Shell App already exists before attempting to import. Right now we only match by the application name defined in the definition, so unless we first perform a check, we will have two Shell Apps imported with the same details.</p>

<p>The following code should either return null or an existing Shell App that matches the name defined in the application definition:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ShellApp</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ShellApp</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="o">.</span><span class="nf">items</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">name</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$Def</span><span class="o">.</span><span class="nf">name</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If no Shell App is returned, we can then use <code class="language-plaintext highlighter-rouge">New-ShellApp</code> to create the Nerdio Manager Shell App for Visual Studio Code. This function requires the application definition object from <code class="language-plaintext highlighter-rouge">Get-ShellAppDefinition</code> and the Evergreen object from <code class="language-plaintext highlighter-rouge">Get-EvergreenAppDetail</code>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$null</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$ShellApp</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">New-ShellApp</span><span class="w"> </span><span class="nt">-Definition</span><span class="w"> </span><span class="nv">$Def</span><span class="w"> </span><span class="nt">-AppDetail</span><span class="w"> </span><span class="nv">$App</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This function provides output that looks similar to the below. Note that file name for the uploaded binary, in this case <code class="language-plaintext highlighter-rouge">6ba28b61c8aeb0cc506dff509d2e5d11.VSCodeSetup-x64-1.102.2.exe</code>. The MD5 hash of the Sha256 file hash is appended to the file name to create a idempotent file name for the uploaded binary so that we can uniquely identify the installer for a specific Shell App version.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Downloaded</span><span class="w"> </span><span class="nx">file:</span><span class="w"> </span><span class="nx">/Users/aaron/Temp/shell-apps/VSCodeSetup-x64-1.102.2.exe</span><span class="w">
</span><span class="n">Get</span><span class="w"> </span><span class="nx">storage</span><span class="w"> </span><span class="nx">account</span><span class="w"> </span><span class="nx">key</span><span class="w"> </span><span class="nx">from:</span><span class="w"> </span><span class="nx">rg-Avd-Images-wus3</span><span class="w"> </span><span class="nx">/</span><span class="w"> </span><span class="nx">stavd7urlg3fm4odtn</span><span class="w">
</span><span class="n">Uploading</span><span class="w"> </span><span class="nx">file</span><span class="w"> </span><span class="nx">to</span><span class="w"> </span><span class="nx">blob:</span><span class="w"> </span><span class="nx">6ba28b61c8aeb0cc506dff509d2e5d11.VSCodeSetup-x64-1.102.2.exe</span><span class="w">
</span><span class="n">Uploaded</span><span class="w"> </span><span class="nx">file</span><span class="w"> </span><span class="nx">to</span><span class="w"> </span><span class="nx">blob:</span><span class="w"> </span><span class="nx">https://stavd7urlg3fm4odtn.blob.core.windows.net/shell-apps/6ba28b61c8aeb0cc506dff509d2e5d11.VSCodeSetup-x64-1.102.2.exe</span><span class="w">
</span><span class="kr">Using</span><span class="w"> </span><span class="n">SAS</span><span class="w"> </span><span class="nx">token</span><span class="w"> </span><span class="nx">for</span><span class="w"> </span><span class="nx">source</span><span class="w"> </span><span class="nx">URL.</span><span class="w">
</span><span class="n">Shell</span><span class="w"> </span><span class="nx">App</span><span class="w"> </span><span class="nx">created</span><span class="w"> </span><span class="nx">successfully.</span><span class="w"> </span><span class="nx">Id:</span><span class="w"> </span><span class="nx">20620</span><span class="w">
</span></code></pre></div></div>

<h3 id="update-the-shell-app-with-a-new-version">Update the Shell App with a new version</h3>

<p>Where a Shell App already exists and a new version is available, we can use the following code to add a new version to an existing Shell App. This reads the version from the Shell App and compares the version against what Evergreen has found. If Evergreen finds a newer version, <code class="language-plaintext highlighter-rouge">New-ShellAppVersion</code> will add a new version to the same Shell App:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ExistingVersions</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ShellAppVersion</span><span class="w"> </span><span class="nt">-Id</span><span class="w"> </span><span class="nv">$ShellApp</span><span class="o">.</span><span class="nf">Id</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="o">.</span><span class="nf">items</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">name</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$App</span><span class="o">.</span><span class="nf">Version</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$null</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$ExistingVersions</span><span class="w"> </span><span class="o">-or</span><span class="w"> </span><span class="p">[</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$ExistingVersions</span><span class="o">.</span><span class="nf">name</span><span class="w"> </span><span class="o">-lt</span><span class="w"> </span><span class="p">[</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$App</span><span class="o">.</span><span class="nf">Version</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">New-ShellAppVersion</span><span class="w"> </span><span class="nt">-Id</span><span class="w"> </span><span class="nv">$ShellApp</span><span class="o">.</span><span class="nf">Id</span><span class="w"> </span><span class="nt">-AppDetail</span><span class="w"> </span><span class="nv">$App</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Downloaded</span><span class="w"> </span><span class="nx">file:</span><span class="w"> </span><span class="nx">/Users/aaron/Temp/shell-apps/VSCodeSetup-x64-1.202.2.exe</span><span class="w">
</span><span class="n">Get</span><span class="w"> </span><span class="nx">storage</span><span class="w"> </span><span class="nx">account</span><span class="w"> </span><span class="nx">key</span><span class="w"> </span><span class="nx">from:</span><span class="w"> </span><span class="nx">rg-Avd-Images-wus3</span><span class="w"> </span><span class="nx">/</span><span class="w"> </span><span class="nx">stavd7urlg3fm4odtn</span><span class="w">
</span><span class="n">Uploading</span><span class="w"> </span><span class="nx">file</span><span class="w"> </span><span class="nx">to</span><span class="w"> </span><span class="nx">blob:</span><span class="w"> </span><span class="nx">6ba28b61c8aeb0cc506dff509d2e5d11.VSCodeSetup-x64-1.202.2.exe</span><span class="w">
</span><span class="n">Uploaded</span><span class="w"> </span><span class="nx">file</span><span class="w"> </span><span class="nx">to</span><span class="w"> </span><span class="nx">blob:</span><span class="w"> </span><span class="nx">https://stavd7urlg3fm4odtn.blob.core.windows.net/shell-apps/6ba28b61c8aeb0cc506dff509d2e5d11.VSCodeSetup-x64-1.202.2.exe</span><span class="w">
</span><span class="kr">Using</span><span class="w"> </span><span class="n">SAS</span><span class="w"> </span><span class="nx">token</span><span class="w"> </span><span class="nx">for</span><span class="w"> </span><span class="nx">source</span><span class="w"> </span><span class="nx">URL.</span><span class="w">
</span><span class="n">Shell</span><span class="w"> </span><span class="nx">App</span><span class="w"> </span><span class="nx">version</span><span class="w"> </span><span class="nx">created</span><span class="w"> </span><span class="nx">successfully.</span><span class="w"> </span><span class="nx">Id:</span><span class="w"> </span><span class="nx">20623</span><span class="w">
</span></code></pre></div></div>

<h2 id="summary">Summary</h2>

<p>Using this approach, we can define a set of application to import as Shell Apps into Nerdio Manager and use a script that reads each application definition, finds the latest application version, downloads the binaries, uploads to the Azure storage account and creates the Shell App in Nerdio Manager. See the sample script here: <a href="https://github.com/aaronparker/nerdio/blob/main/shell-apps/Create-ShellApps.ps1">Create-ShellApps.ps1</a>.</p>

<p><img src="/media/2025/07/nerdio-manager-shell-apps.jpeg" alt="" /></p>

<p class="figcaption">Nerdio Manager Shell Apps imported via PowerShell.</p>

<p>In this article, I’ve shown you how to interactively import a set of applications to Nerdio Manager Shell Apps; however, we don’t really want to be sitting in front of a console and running this each time we want to import new apps. In <a href="https://stealthpuppy.com/nerdio-shell-apps-p2/">the next article</a>, I’ll cover updating this workflow for use in an Azure Pipeline to automate the entire process.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="Automation"/><category term="Deployment"/><summary type="html"><![CDATA[An automated pipeline for creating and updating Nerdio Manager Shell Apps with Evergreen.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/shell/image.jpg"/></entry><entry><title type="html">Prepare for Change - Upcoming Evergreen Changes</title><link href="https://stealthpuppy.com/evergreen-change-2025/" rel="alternate" title="Prepare for Change - Upcoming Evergreen Changes" type="text/html"/><published>2025-07-09T00:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/evergreen-change</id><content type="html" xml:base="https://stealthpuppy.com/evergreen-change-2025/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#background" id="markdown-toc-background">Background</a></li>
  <li><a href="#the-issue" id="markdown-toc-the-issue">The issue</a></li>
  <li><a href="#addressing-the-issue" id="markdown-toc-addressing-the-issue">Addressing the issue</a>    <ul>
      <li><a href="#move-evergreen-to-a-github-organisation" id="markdown-toc-move-evergreen-to-a-github-organisation">Move Evergreen to a GitHub organisation</a></li>
      <li><a href="#move-per-application-functions-to-a-dedicated-repository" id="markdown-toc-move-per-application-functions-to-a-dedicated-repository">Move per-application functions to a dedicated repository</a></li>
      <li><a href="#creating-a-method-to-download-per-application-functions" id="markdown-toc-creating-a-method-to-download-per-application-functions">Creating a method to download per-application functions</a></li>
      <li><a href="#how-update-evergreen-works" id="markdown-toc-how-update-evergreen-works">How Update-Evergreen works</a></li>
    </ul>
  </li>
  <li><a href="#faqs" id="markdown-toc-faqs">FAQs</a></li>
</ul>

<h2 id="background">Background</h2>

<p>When the initial version of <a href="https://stealthpuppy.com/evergreen">Evergreen</a> was released, it included support for a handful of applications. Each application was supported as an individual function - for example:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Get-Microsoft365Apps</span><span class="w">
</span></code></pre></div></div>

<p>As the module grew to support additional applications, this approach was not sustainable as discoverability of supported application was difficult. Therefore, the <a href="https://stealthpuppy.com/evergreen/changelog/#2104337">approach was changed to include a single Get function</a> for applications. So this became:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Get-EvergreenApp</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nx">Microsoft365Apps</span><span class="w">
</span></code></pre></div></div>

<h2 id="the-issue">The issue</h2>

<p>Since that time the supported number of applications has grown to 375 while continuing to include all of the per-application functions and manifest in the module. This means that any time a new application is added or a fix to an application is made, an entirely new release of Evergreen is required.</p>

<p>To make changes to the module, I loosely follow standard change control processes by creating a development branch, making the changes to the code, testing those changes, creating and merging a pull request in the main branch, then pushing the new version of the module to the PowerShell gallery.</p>

<p>This is time consuming and can sometimes create issues where someone hasn’t updated the module in their environment that includes the fix.</p>

<h2 id="addressing-the-issue">Addressing the issue</h2>

<p>For a long time, I’ve been looking at separating the per-application functions from the module so they can be updated on demand and newly supported applications or fixes to existing applications can be delivered faster.</p>

<p>An upcoming change to Evergreen will address this issue by separating the per-application functions and manifests from the core module, by storing these in a separate repository and including a method to download and update a locally cached copy of these functions.</p>

<p>Here’s how I’m proposing to make these changes, and <strong>I’m welcoming comments and feedback before this change is implemented</strong>.</p>

<h3 id="move-evergreen-to-a-github-organisation">Move Evergreen to a GitHub organisation</h3>

<p>To simplify discoverability of the various code repositories related to Evergreen, I’ll be moving the Evergreen GitHub repo to the <a href="https://github.com/EUCPilots">EUC Pilots</a> organisation. This will still essentially be managed by me, but I think this approach will improve branding and make it simpler to organise repositories.</p>

<p>I may move various Evergreen related sites (e.g. the documentation) away from https://stealthpuppy.com to https://eucpilots.com. I am also looking at moving <a href="https://github.com/aaronparker/vcredist">VcRedist</a> to this organisation as well, as it’s closely related to Evergreen.</p>

<h3 id="move-per-application-functions-to-a-dedicated-repository">Move per-application functions to a dedicated repository</h3>

<p>The per-application functions and manifests will be moved to a dedicated repository in this organisation. You can see that repository here: <a href="https://github.com/EUCPilots/evergreen-apps">evergreen-apps</a>.</p>

<p>This repository will host the <code class="language-plaintext highlighter-rouge">Apps</code> and <code class="language-plaintext highlighter-rouge">Manifests</code> directories included in the module today, moving them out of the module and making it easier to make changes to these functions.</p>

<p>This repository includes <a href="https://github.com/EUCPilots/evergreen-apps/blob/main/.github/workflows/validate-release.yml">a release workflow</a> the performs the following:</p>

<ol>
  <li>Validate all PowerShell functions - this still needs to be added, but Pester tests are used for validation of Evergreen today.</li>
  <li>Validate the JSON manifests - the manifest need to have some basic validation applied.</li>
  <li>Store SHA256 hashes for each PowerShell file and manifest. This enables validation when downloading the files locally.</li>
  <li>Create a release for changes made to the application functions. Releases will include a list of changed files and with a version number in the format “yy.mm.dd.run”, e.g. <code class="language-plaintext highlighter-rouge">25.07.06.2</code>. The release will include a zip file containing a copy of the <code class="language-plaintext highlighter-rouge">Apps</code> and <code class="language-plaintext highlighter-rouge">Manifests</code> directories with a <a href="https://github.blog/changelog/2025-06-03-releases-now-expose-digests-for-release-assets/">SHA256 hash of the file</a>.</li>
</ol>

<p><img src="/media/2025/07/evergreen-apps-release.jpeg" alt="Screenshot of a release on the evergreen-apps repository" /></p>

<p>Any time changes are pushed to the <code class="language-plaintext highlighter-rouge">main</code> branch in this repository, a new release will be created, so that updated functions are available to download.</p>

<h3 id="creating-a-method-to-download-per-application-functions">Creating a method to download per-application functions</h3>

<p>When importing the Evergreen module, you’ll be prompted to download the per-application functions:</p>

<p><img src="/media/2025/07/import-evergreen.png" alt="Importing Evergreen and being prompted to run Update-Evergreen" /></p>

<p>A new function has been added to Evergreen named <code class="language-plaintext highlighter-rouge">Update-Evergreen</code>. This downloads the latest release from the <code class="language-plaintext highlighter-rouge">evergreen-apps</code> repository, unpacks the files and stores them locally.</p>

<p><img src="/media/2025/07/update-evergreen.gif" alt="Running Update-Evergreen for the first time" /></p>

<p>This function supports the <code class="language-plaintext highlighter-rouge">-Force</code> parameter to force the download of the latest release of the per-application functions even if you already have these locally. This approach should enable the administrator to update Evergreen where the locally cached copies of the functions are perhaps broken.</p>

<p><img src="/media/2025/07/update-evergreen-force.gif" alt="Running Update-Evergreen with the -Force parameter" /></p>

<p>When importing Evergreen where the local cache is out of date, you will be prompted to update:</p>

<p><img src="/media/2025/07/import-evergreen.gif" alt="Importing Evergreen and being prompted to run Update-Evergreen" /></p>

<h3 id="how-update-evergreen-works">How Update-Evergreen works</h3>

<p>To facilite downloading and updating the per-application functions and to ensure that downloaded files are valid, <code class="language-plaintext highlighter-rouge">Update-Evergreen</code> performs the following steps:</p>

<ol>
  <li>Per-application functions and manifests will be stored in the the following default locations - on Windows in <code class="language-plaintext highlighter-rouge">%LocalAppData%\Evergreen</code> and on macOS or Linux in <code class="language-plaintext highlighter-rouge">~/.evergreen</code>.</li>
  <li>These locations can be overridden by setting an environment variable named <code class="language-plaintext highlighter-rouge">EVERGREEN_APPS_PATH</code> pointing to a path of your choice.</li>
  <li>The locally cached per-application functions and manifests are checked against the list of expected SHA256 hashes (stored <a href="https://github.com/EUCPilots/evergreen-apps/blob/main/sha256_hashes.csv">here</a>). If the hashes do not match, the administrator is prompted to run <code class="language-plaintext highlighter-rouge">Update-Evergreen -Force</code> - they won’t automatically be updated unless there is a new version on the <code class="language-plaintext highlighter-rouge">evergreen-apps</code> repository.</li>
  <li>The updated version of the per-application functions and manifests is downloaded from the latest release (i.e. the zip file).</li>
  <li>The downloaded zip file is compared against the SHA256 hash stored on the GtiHub release object</li>
  <li>After downloading unpacking the zip file, the included files are compared against the expected SHA256 hashes. If they do not match, the locally cached copies are not updated.</li>
  <li>If they do match, the local copies will be updated and Evergreen will now support the latest apps.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">Update-Evergreen</code> is recommended as the simplest option to update to the latest version of the per-application functions and manifests; however, there’s no reason an administrator couldn’t do that manually, maintaining some control.</p>

<h2 id="faqs">FAQs</h2>

<p><strong>Q</strong>. When will this happen?</p>

<p><strong>A</strong>. I’m not 100% certain, but it’s likely to be around 6-8 weeks from the posting of this article (toward the end of August 2025).</p>

<p><strong>Q</strong>. Why download the zip file rather than update individual functions?</p>

<p><strong>A</strong>. I think this is the simplest approach - it enables the per-application functions and manifests to be tracked as a specific release and reduces the calls to GitHub (unautheticated calls to api.github.com are limited to 60 per hour). It also simplifies downloading the files by doing so in a single action. If required, <code class="language-plaintext highlighter-rouge">Update-Evergreen</code> could perhaps support downloading a specific release rather than the latest release.</p>

<p><strong>Q</strong>. Can I test these changes before release?</p>

<p><strong>A</strong>. Yes, view and test the changes in the <a href="https://github.com/aaronparker/evergreen/tree/split-repo">split-repo</a> branch on the Evergreen repository. Please provide bugs and feedback so that I can make improvements before release.</p>

<p><strong>Q</strong>. What will I need to do to update my scripts?</p>

<p><strong>A</strong>. Update scripts to run the <code class="language-plaintext highlighter-rouge">Update-Evergreen</code> function before using any further Evergeen functions. For example:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Install-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"Evergreen"</span><span class="w">
</span><span class="n">Import-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"Evergreen"</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="n">Update-Evergreen</span><span class="w">
</span></code></pre></div></div>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="PowerShell"/><category term="Automation"/><summary type="html"><![CDATA[Some big changes are coming to Evergreen, so be prepared to update your scripts and pipelines to ensure things don't break.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/evergreen/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/evergreen/image.jpg"/></entry><entry><title type="html">Streamlining App Management with Evergreen &amp;amp; Rimo3</title><link href="https://stealthpuppy.com/rimo3-evergreen/" rel="alternate" title="Streamlining App Management with Evergreen &amp;amp; Rimo3" type="text/html"/><published>2025-05-02T07:36:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/rimo3-evergreen</id><content type="html" xml:base="https://stealthpuppy.com/rimo3-evergreen/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#purpose" id="markdown-toc-purpose">Purpose</a></li>
  <li><a href="#about-rimo3" id="markdown-toc-about-rimo3">About Rimo3</a></li>
  <li><a href="#about-evergreen" id="markdown-toc-about-evergreen">About Evergreen</a></li>
  <li><a href="#about-the-solution" id="markdown-toc-about-the-solution">About the solution</a>    <ul>
      <li><a href="#components" id="markdown-toc-components">Components</a></li>
      <li><a href="#workflow-process" id="markdown-toc-workflow-process">Workflow process</a></li>
    </ul>
  </li>
  <li><a href="#under-the-hood" id="markdown-toc-under-the-hood">Under the hood</a>    <ul>
      <li><a href="#application-install" id="markdown-toc-application-install">Application install</a></li>
      <li><a href="#initial-application-list" id="markdown-toc-initial-application-list">Initial application list</a></li>
      <li><a href="#authenticating-to-the-rimo3-api" id="markdown-toc-authenticating-to-the-rimo3-api">Authenticating to the Rimo3 API</a></li>
      <li><a href="#importing-an-application" id="markdown-toc-importing-an-application">Importing an application</a></li>
    </ul>
  </li>
  <li><a href="#orchestration" id="markdown-toc-orchestration">Orchestration</a>    <ul>
      <li><a href="#workflow-execution" id="markdown-toc-workflow-execution">Workflow execution</a></li>
      <li><a href="#updating-packages" id="markdown-toc-updating-packages">Updating packages</a></li>
      <li><a href="#secrets" id="markdown-toc-secrets">Secrets</a></li>
    </ul>
  </li>
  <li><a href="#summary" id="markdown-toc-summary">Summary</a></li>
</ul>

<p>I’m really pleased to release a solution that integrates the <a href="https://stealthpuppy.com/evergreen">Evergreen PowerShell module</a> with <a href="https://www.rimo3.com/products">Rimo3</a>. This enables you to use Evergreen as a trusted source for your application packages and Rimo3 to test and validate those applications before importing them into Microsoft Intune.</p>

<h2 id="purpose">Purpose</h2>

<p>This solution enables organisations to have direct visibility into their application sources - because Evergreen runs in your environment and only communicates with approved vendor source locations, you can guarantee the trustworthiness of the application binaries before import.</p>

<p>The workflow provides a way to upload pre-configured application packages to the Rimo3 platform using a manual trigger. So any application supported by Evergreen can be used with workflow by creating an install wrapper with the PSAppDeployToolkit and imported into Rimo3. As new versions of applications are made available, import into Rimo3 is made simple with automated discovery with Evergreen.</p>

<h2 id="about-rimo3">About Rimo3</h2>

<p>Rimo3 is a comprehensive platform designed for modernizing and managing enterprise workspaces, ensuring that IT teams can transition smoothly to modern environments like Windows 365, Windows 11, Azure Virtual Desktop, and Intune. One of its standout features is its robust approach to application lifecycle management — a process that covers every phase of an application’s existence within an IT ecosystem.</p>

<p>At its core, Rimo3 automates several key tasks that traditionally require significant manual effort. It automatically discovers the full inventory of applications within an organization, ensuring that nothing is overlooked. Once apps are identified, the platform systematically validates them against specific environmental criteria to check for compatibility and performance, which is crucial before any change is deployed. After validation, Rimo3 helps package the applications into modern deployment formats (like Win32 or MSIX) that align with contemporary management frameworks. Finally, it streamlines patch management by automating the testing and deployment of application updates, reducing the likelihood of disruptions or performance issues that can arise from manual patching processes.</p>

<p>By employing automation at each stage—from discovery through to patch deployment — Rimo3 transforms what is often a complex, error-prone manual process into a smooth and efficient workflow. This not only bolsters security and operational continuity but also frees IT teams to focus on more strategic tasks, reducing downtime and minimizing risk across the entire application ecosystem.</p>

<h2 id="about-evergreen">About Evergreen</h2>

<p><a href="https://stealthpuppy.com/evergreen">Evergreen</a> is a PowerShell module that automatically retrieves the latest version information and download URLs for a range of common Windows applications by directly querying the vendors’ update APIs. Rather than relying on third-party aggregators, the module pulls data directly from the source, ensuring that the information is both current and trustworthy. This enables you to import application packages into Rimo3 with full visibility into the application sources and reduce supply chain attacks.</p>

<p>Here’s an example - let’s use Evergreen to find the latest version of the Microsoft SQL Server Management Studio. Using the <code class="language-plaintext highlighter-rouge">Get-EvergreenApp</code> command, Evergreen will query the Microsoft site and return a list of the available installers. With this detail, we can check whether the latest version is already imported into Rimo3 and if not, download, package, and import into Rimo3:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Get-EvergreenApp</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"MicrosoftSsms"</span><span class="w">

</span><span class="n">Version</span><span class="w">   </span><span class="nx">Date</span><span class="w">     </span><span class="nx">Language</span><span class="w">              </span><span class="nx">URI</span><span class="w">
</span><span class="o">-------</span><span class="w">   </span><span class="o">----</span><span class="w">     </span><span class="o">--------</span><span class="w">              </span><span class="o">---</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">English</span><span class="w">               </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-ENU.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">French</span><span class="w">                </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-FRA.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">German</span><span class="w">                </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-DEU.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Italian</span><span class="w">               </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-ITA.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Japanese</span><span class="w">              </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-JPN.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Korean</span><span class="w">                </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-KOR.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Portuguese</span><span class="w"> </span><span class="p">(</span><span class="n">Brazil</span><span class="p">)</span><span class="w">   </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-PTB.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Russian</span><span class="w">               </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-RUS.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Spanish</span><span class="w">               </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-ESN.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Chinese</span><span class="w"> </span><span class="p">(</span><span class="n">Simplified</span><span class="p">)</span><span class="w">  </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-CHS.exe</span><span class="w">
</span><span class="mf">20.1</span><span class="o">.</span><span class="nf">10</span><span class="o">.</span><span class="nf">0</span><span class="w"> </span><span class="mi">3</span><span class="n">/4/2024</span><span class="w"> </span><span class="nx">Chinese</span><span class="w"> </span><span class="p">(</span><span class="n">Traditional</span><span class="p">)</span><span class="w"> </span><span class="nx">https://download.microsoft.com/download/7519f0ff-997c-4f36-b5aa-9a51d47dd34c/SSMS-Setup-CHT.exe</span><span class="w">
</span></code></pre></div></div>

<h2 id="about-the-solution">About the solution</h2>

<p>This solution demonstrates to customers of Rimo3 how to use Evergreen in an automated workflow to download the latest version of an application, wrap the installer with the <a href="https://psappdeploytoolkit.com/">PowerShell App Deployment Toolkit</a>, and import into Rimo3.</p>

<p>The solution is provided in <a href="https://github.com/aaronparker/rimo3">a GitHub repository</a> and includes workflows for GitHub and Azure Pipelines. The workflows run the <code class="language-plaintext highlighter-rouge">Start-PackageUpload.ps1</code> script which can be run outside of the workflow process (on other platforms or manually).</p>

<p>To use this in your own environment, fork the repository or copy the code and modify to run on your platform of choice.</p>

<h3 id="components">Components</h3>

<p>The workflow can be run via GitHub Actions or Azure Pipelines and uses the following components:</p>

<ul>
  <li>Evergreen - you can view the list of supported applications in the <a href="https://stealthpuppy.com/apptracker/">Evergreen App Tracker</a></li>
  <li>PSAppDeployToolkit - this provides an install wrapper for the target application and simplifies the application definition when importing into Rimo3. Additionally, standardising on the PSAppDeployToolkit for application installs enables a consistent approach and the ability to interest with the <a href="https://psappdeploytoolkit.com/docs/getting-started/faq">end-user during an application install</a></li>
  <li>Rimo3 and the Rimo3 API - the API is leveraged to import application packages into Rimo3, including defining how the application package should be processed (Import + Discovery + Baseline + Test + Export to Intune)</li>
</ul>

<p>When a new version of an application is available, the workflow can be re-run to import the new version into Rimo3 for testing and validation, and if validation is successful, export to Intune.</p>

<p><a href="/media/2025/04/rimo3-01.jpeg"><img src="/media/2025/04/rimo3-01.jpeg" alt="Application packages imported into Rimo3" /></a></p>

<p class="figcaption">Application packages imported into Rimo3.</p>

<h3 id="workflow-process">Workflow process</h3>

<p>The repository includes workflows for GitHub Actions and Azure Pipelines and supports the import of a single application package; however, multiple packages can be provided to <code class="language-plaintext highlighter-rouge">Start-PackageUpload.ps1</code>.</p>

<p>Here’s a high-level look at the workflow process:</p>

<ol>
  <li>The Evergreen PowerShell module must be installed before running the workflow. The module is updated approximately every 6 weeks, so ensure the latest version is always installed.</li>
  <li>Credentials for the API need to be protected, so they can be securely stored as <a href="https://docs.github.com/en/get-started/learning-to-code/storing-your-secrets-safely">GitHub Secrets</a> or in an <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/library/?view=azure-devops">Azure Pipelines asset library</a>.</li>
  <li>The workflow will first check whether the same version of the application has already been imported. If it finds a matching version it will not continue.</li>
  <li>Each application package includes an <code class="language-plaintext highlighter-rouge">App.json</code> file that describes the application including the filter that Evergreen should use to determine the application installer to use</li>
  <li>The PSAppDeployToolkit 4 is used, and application install and uninstall logic is included in <code class="language-plaintext highlighter-rouge">Invoke-AppDeployToolkit.ps1</code> for each package.</li>
  <li>The workflow supports EXE, MSI, and MSIX installers, including installers that may be provided as zip files (which require extracting before packaging and sending to Rimo3)</li>
  <li>During packaging, the latest installer is downloaded and included with the PSAppDeployToolkit. The workflow readies the package for Rimo3 and uploads the package to Rimo3 for processing</li>
  <li>When the package is successfully uploaded to Rimo3, you can then monitor the processing of the application in the Rimo3 console</li>
</ol>

<p><a href="/media/2025/04/rimo3-03.jpeg"><img src="/media/2025/04/rimo3-03.jpeg" alt="Application processing in Rimo3" /></a></p>

<p class="figcaption">Application packages actively being imported into Rimo3.</p>

<h2 id="under-the-hood">Under the hood</h2>

<h3 id="application-install">Application install</h3>

<p>Each application package includes at least two files:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">App.json</code> - this file describes the application including how Evergreen should be used to find the application version and binaries, application name and version etc. This also allows for some separation of changing application versions and the PSAppDeployToolkit which is typically static. This file is updated with Evergreen to ensure it includes details of the latest version of each application</li>
  <li><code class="language-plaintext highlighter-rouge">Invoke-AppDeployToolkit.ps1</code> - this is the primary PSAppDeployToolkit installation and uninstall script for each application, so it includes application specific logic for each application.</li>
</ul>

<p><a href="/media/2025/04/package.png"><img src="/media/2025/04/package.png" alt="Application package template files" /></a></p>

<p class="figcaption">Template files for an application package.</p>

<p>The following code can be found in <code class="language-plaintext highlighter-rouge">Invoke-AppDeployToolkit.ps1</code> which reads <code class="language-plaintext highlighter-rouge">App.json</code> to find detail of the target application and minimise changes to this script for each application update:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Read App.json to get details for the app</span><span class="w">
</span><span class="nv">$AppJson</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PSScriptRoot</span><span class="s2">\App.json"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">

</span><span class="c"># Get the installer file specified in the App.json</span><span class="w">
</span><span class="nv">$</span><span class="nn">Global</span><span class="p">:</span><span class="nv">Installer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ChildItem</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$AppJson</span><span class="o">.</span><span class="nf">PackageInformation</span><span class="o">.</span><span class="nf">SetupFile</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w">
</span></code></pre></div></div>

<h3 id="initial-application-list">Initial application list</h3>

<p>The project repository includes the following applications:</p>

<ul>
  <li>Audacity</li>
  <li>Citrix Workspace App (Current release)</li>
  <li>Cyberduck</li>
  <li>Foxit Reader</li>
  <li>Google Chrome</li>
  <li>ImageGlass</li>
  <li>Microsoft PowerToys</li>
  <li>Microsoft SQL Server Management Studio</li>
  <li>Microsoft Visual Studio Code</li>
  <li>Microsoft Azure Virtual Desktop Remote Desktop Client</li>
  <li>Mozilla Firefox</li>
  <li>Notepad++</li>
  <li>Paint.NET</li>
  <li>ScreenToGif</li>
  <li>Tracker Software PDFX Change Editor</li>
  <li>VideoLan VLC Player</li>
</ul>

<p>The approach taken in this project is similar to my <a href="https://github.com/aaronparker/packagefactory">PSPackageFactory for Intune</a>, thus more applications can be added quite easily.</p>

<h3 id="authenticating-to-the-rimo3-api">Authenticating to the Rimo3 API</h3>

<p>Authenticating to the Rimo3 API requires constructing a form with credentials to the API. You can find details in this article <a href="https://learn.rimo3.com/knowledge-base/rimo3-public-api-token-migration">Rimo3 API - New endpoint for generating an API Access Token</a>.</p>

<p>Here’s how this looks - the client ID and secret used to authenticate to the API should be securely stored. In this example, these values have been passed to the script, encoded and used with <strong>Invoke-WebRequest</strong> to post the credentials and return an authentication token.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$EncodedString</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetBytes</span><span class="p">(</span><span class="s2">"</span><span class="nv">${ClientId}</span><span class="s2">:</span><span class="nv">$ClientSecret</span><span class="s2">"</span><span class="p">)</span><span class="w">
</span><span class="nv">$Base64String</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Convert</span><span class="p">]::</span><span class="n">ToBase64String</span><span class="p">(</span><span class="nv">$EncodedString</span><span class="p">)</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Uri</span><span class="w">             </span><span class="o">=</span><span class="w"> </span><span class="s2">"https://rimo3cloud.com/api/v2/connect/token"</span><span class="w">
    </span><span class="nx">Body</span><span class="w">            </span><span class="o">=</span><span class="w"> </span><span class="s2">"{</span><span class="se">`"</span><span class="s2">Form-Data</span><span class="se">`"</span><span class="s2">: </span><span class="se">`"</span><span class="s2">grant_type=client_credentials</span><span class="se">`"</span><span class="s2">}"</span><span class="w">
    </span><span class="nx">Headers</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="s2">"Authorization"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Basic </span><span class="nv">$Base64String</span><span class="s2">"</span><span class="w">
        </span><span class="s2">"Cache-Control"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"no-cache"</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nx">Method</span><span class="w">          </span><span class="o">=</span><span class="w"> </span><span class="s2">"POST"</span><span class="w">
    </span><span class="nx">UseBasicParsing</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">ErrorAction</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"Stop"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="nv">$Token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>

<h3 id="importing-an-application">Importing an application</h3>

<p>Details on how to use the API to import an application package can be found here: <a href="https://learn.rimo3.com/knowledge-base/rimo3-api-import-an-application">Rimo3 API - Import an Application</a>.</p>

<p>Within the workflow, once the application binaries have been downloaded, and included with a PSAppDeployToolkit template, the package is compressed into a single zip file and posted to the Rimo3 API to import the application package. To provide the API with the information required to describe the application package, details from <code class="language-plaintext highlighter-rouge">App.json</code> are used, including the package display name, publisher and version information.</p>

<p>Here’s how uploading the application package looks in detail:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Uri</span><span class="w">             </span><span class="o">=</span><span class="w"> </span><span class="s2">"https://rimo3cloud.com/api/v2/application-packages/upload/manual"</span><span class="w">
    </span><span class="nx">Method</span><span class="w">          </span><span class="o">=</span><span class="w"> </span><span class="s2">"POST"</span><span class="w">
    </span><span class="nx">Headers</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="s2">"accept"</span><span class="w">        </span><span class="o">=</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
        </span><span class="s2">"Authorization"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Bearer </span><span class="si">$(</span><span class="nv">$Token</span><span class="o">.</span><span class="nf">access_token</span><span class="si">)</span><span class="s2">"</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nx">Form</span><span class="w">            </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="s2">"file"</span><span class="w">             </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">Get</span><span class="err">-</span><span class="nx">Item</span><span class="w"> </span><span class="err">-</span><span class="nx">Path</span><span class="w"> </span><span class="nv">$ZipFile</span><span class="err">.</span><span class="nx">FullName</span><span class="err">)</span><span class="w">
        </span><span class="s2">"displayName"</span><span class="w">      </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">Information</span><span class="err">.</span><span class="nx">DisplayName</span><span class="w">
        </span><span class="s2">"comment"</span><span class="w">          </span><span class="o">=</span><span class="w"> </span><span class="s2">"Imported by Evergreen"</span><span class="w">
        </span><span class="s2">"fileName"</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">PackageInformation</span><span class="err">.</span><span class="nx">SetupFile</span><span class="w">
        </span><span class="s2">"publisher"</span><span class="w">        </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">Information</span><span class="err">.</span><span class="nx">Publisher</span><span class="w">
        </span><span class="s2">"name"</span><span class="w">             </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">Application</span><span class="err">.</span><span class="nx">Title</span><span class="w">
        </span><span class="s2">"version"</span><span class="w">          </span><span class="o">=</span><span class="w"> </span><span class="nv">$EvergreenApp</span><span class="err">.</span><span class="nx">Version</span><span class="w">
        </span><span class="s2">"installCommand"</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">Program</span><span class="err">.</span><span class="nx">InstallCommand</span><span class="w">
        </span><span class="s2">"uninstallCommand"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$AppJson</span><span class="err">.</span><span class="nx">Program</span><span class="err">.</span><span class="nx">UninstallCommand</span><span class="w">
        </span><span class="s2">"tags"</span><span class="w">             </span><span class="o">=</span><span class="w"> </span><span class="nv">$Tags</span><span class="w">
        </span><span class="s2">"progressStep"</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"2"</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nx">ContentType</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"multipart/form-data"</span><span class="w">
    </span><span class="nx">UseBasicParsing</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">ErrorAction</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"Continue"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>

<p>Note the value for <code class="language-plaintext highlighter-rouge">progressStep</code> in this sample - the workflow defaults to a value of <strong>2</strong> - Import + Discovery + Baseline. This value needs to be changed to <strong>3</strong> to enable Import + Discovery + Baseline + Test.</p>

<p>Once the application package has been imported, you can view its details. Note the file name and install command in the screenshot below, showing the PSAppDeployToolkit components and syntax.</p>

<p><a href="/media/2025/04/rimo3-02.jpeg"><img src="/media/2025/04/rimo3-02.jpeg" alt="Application details in Rimo3" /></a></p>

<p class="figcaption">Application packages details in Rimo3.</p>

<h2 id="orchestration">Orchestration</h2>

<h3 id="workflow-execution">Workflow execution</h3>

<p>There are many ways that you can orchestrate the import of application packages. I typically default to Azure Pipelines or GitHub Actions because these platforms integrate with the code repository and make it simple to schedule workflow execution.</p>

<p>The workflow to import an application package into Rimo3 has been included for <a href="https://github.com/aaronparker/rimo3/blob/main/.azure/pipelines/packageupload.yml">Azure Pipelines</a> and <a href="https://github.com/aaronparker/rimo3/blob/main/.github/workflows/packageupload.yml">GitHub Actions</a>. By default this workflow is run manually and allows you to select an application to import.</p>

<p>Here’s the Azure Pipelines version:</p>

<p><a href="/media/2025/04/azure-pipelines.jpeg"><img src="/media/2025/04/azure-pipelines.jpeg" alt="Running the package import workflow in Azure Pipelines" /></a></p>

<p class="figcaption">Running the package import workflow in Azure Pipelines.</p>

<p>And here is the GitHub Actions version:</p>

<p><a href="/media/2025/04/github-workflow.jpeg"><img src="/media/2025/04/github-workflow.jpeg" alt="Running the package import workflow in GitHub Actions" /></a></p>

<p class="figcaption">Running the package import workflow in GitHub Actions.</p>

<h3 id="updating-packages">Updating packages</h3>

<p>The repository includes a workflow (update-packagejson) that leverages Evergreen to update the <code class="language-plaintext highlighter-rouge">App.json</code> file for each application. This currently runs once every 24 hours, checks for application updates with Evergreen, and commits changes back to the repository. This process can be done at packaging time; however, it enables a trigger that can be used to start import on detection of a new version of an application.</p>

<h3 id="secrets">Secrets</h3>

<p>Add the required secrets to the repository to enable the <code class="language-plaintext highlighter-rouge">Start-PackageUpload.ps1</code> script to authenticate to the Rimo3 API:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CLIENT_ID</code> - Authentication client ID</li>
  <li><code class="language-plaintext highlighter-rouge">CLIENT_SECRET</code> - secret value to authenticate with the client ID</li>
</ul>

<p>The following secrets are used by the <code class="language-plaintext highlighter-rouge">update-packagejson</code> workflow to commit changes and sign git commits:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">COMMIT_EMAIL</code> - Email address used for commits</li>
  <li><code class="language-plaintext highlighter-rouge">COMMIT_NAME</code> - Display name used for commits</li>
  <li><code class="language-plaintext highlighter-rouge">GPGKEY</code> - Signing key for commits (optional - remove signing options from the workflow if required)</li>
  <li><code class="language-plaintext highlighter-rouge">GPGPASSPHRASE</code> - Passphrase used to unlock the key during commits (optional - remove signing options from the workflow if required)</li>
</ul>

<p>If you’re running the solution in GitHub Actions, configure the repository secrets:</p>

<p><a href="/media/2025/04/github-secrets.jpeg"><img src="/media/2025/04/github-secrets.jpeg" alt="GitHub Secrets" /></a></p>

<p class="figcaption">GitHub Secrets required by the solution, including secrets used by workflows that automatically update the source based on application updates.</p>

<p>If you’re running the solution in Azure Pipelines, configure a variable group and ensure the authentication values are protected:</p>

<p><a href="/media/2025/04/azure-secrets.jpeg"><img src="/media/2025/04/azure-secrets.jpeg" alt="Azure Pipelines secrets" /></a></p>

<p class="figcaption">Azure Pipelines secrets required by the solution, including secrets used by workflows that automatically update the source based on application updates.</p>

<h2 id="summary">Summary</h2>

<p>Evergreen is a natural complement to the Rimo3 platform, providing you with a trusted source for your application packages. With the solution provided here, you leverage Evergreen and Rimo3 to discover, validate and modernise your application lifecycle management.</p>

<p>Star, fork and contribute to the project on GitHub here: <a href="https://github.com/aaronparker/rimo3">Rimo3 + Evergreen</a>.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><category term="Evergreen"/><category term="Automation"/><category term="Deployment"/><summary type="html"><![CDATA[Using Evergreen and the Rimo3 API to automatically import applications into Rimo3 for discovery, baseline and testing.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/rimo3/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/rimo3/image.jpg"/></entry><entry><title type="html">A Mac mini as a home server</title><link href="https://stealthpuppy.com/mac-mini-home-server/" rel="alternate" title="A Mac mini as a home server" type="text/html"/><published>2024-12-23T00:00:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/mac-mini-home-server</id><content type="html" xml:base="https://stealthpuppy.com/mac-mini-home-server/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#i-need-a-new-server" id="markdown-toc-i-need-a-new-server">I need a new server</a></li>
  <li><a href="#why-a-mac-mini" id="markdown-toc-why-a-mac-mini">Why a Mac mini</a></li>
  <li><a href="#set-up-a-mac-mini-as-a-server" id="markdown-toc-set-up-a-mac-mini-as-a-server">Set up a Mac mini as a server</a>    <ul>
      <li><a href="#hardware-setup" id="markdown-toc-hardware-setup">Hardware Setup</a></li>
      <li><a href="#first-boot-setup" id="markdown-toc-first-boot-setup">First Boot Setup</a></li>
      <li><a href="#performance-and-power-settings" id="markdown-toc-performance-and-power-settings">Performance and Power Settings</a></li>
      <li><a href="#sharing-settings" id="markdown-toc-sharing-settings">Sharing Settings</a></li>
      <li><a href="#homebrew" id="markdown-toc-homebrew">Homebrew</a></li>
      <li><a href="#dns-filtering-with-adguard-home" id="markdown-toc-dns-filtering-with-adguard-home">DNS filtering with AdGuard Home</a></li>
      <li><a href="#extending-apple-homekit-with-homebridge" id="markdown-toc-extending-apple-homekit-with-homebridge"><del>Extending Apple HomeKit with HomeBridge</del></a></li>
      <li><a href="#remote-hardware-monitoring-with-istatistica" id="markdown-toc-remote-hardware-monitoring-with-istatistica">Remote hardware monitoring with iStatistica</a></li>
      <li><a href="#serving-up-media-with-plex-server" id="markdown-toc-serving-up-media-with-plex-server">Serving up media with Plex Server</a></li>
      <li><a href="#download-torrents-with-transmission" id="markdown-toc-download-torrents-with-transmission">Download torrents with Transmission</a></li>
    </ul>
  </li>
  <li><a href="#usage-and-observations" id="markdown-toc-usage-and-observations">Usage and Observations</a></li>
</ul>

<h2 id="i-need-a-new-server">I need a new server</h2>

<p>Back in 2016, I <a href="https://stealthpuppy.com/intel-nuc6i5syb-home-lab/">purchased an Intel NUC</a> for running various workloads - it’s previously run VMs on Hyper-V (Windows 10) or Ubuntu. For the past few years it has been relegated to home network management by running a UniFi Network Server, <a href="https://github.com/AdguardTeam/AdGuardHome#getting-started">AdGuard Home</a> and <a href="https://homebridge.io">Homebridge</a>. At over eight years old now, it’s past its prime and I’ve been looking at a replacement for some time.</p>

<p>A couple of options for replacement devices included:</p>

<ul>
  <li>Raspberry Pi 5 - $260 AUD for a Pi 5, 8GB RAM, 256 GB SSD, SSD NVMe hat, aluminium case and a power supply. This is a great price point for a device that would run a couple of simple services for my home network</li>
  <li>ASUS NUC - $627 AUD for an ASUS NUC 13 Pro Arena Canyon i3, 8GB RAM, 256GB SSD. While this would be more versatile, it’s way above what I’m willing to pay for this project</li>
</ul>

<h2 id="why-a-mac-mini">Why a Mac mini</h2>

<p>Recently I picked up a Mac mini M4 Pro as my primary work device, so the thought occurred to me use another Mac mini as a home server. There’s a few reasons why a Mac would make for a good server:</p>

<ol>
  <li>Low power consumption. The M series chip should sit somewhere between a Raspberry Pi and an x86 15W chip</li>
  <li><a href="https://support.apple.com/en-au/guide/deployment/depde72e125f/web">macOS content caching</a> - with multiple Macs, iPads, iPhones and an Apple TV in the house, we have a good number of devices that can use local caching</li>
  <li>Time Machine backups - I’ve previously used Time Machine on a Synology NAS, but it would fail every so often. Backup to a macOS Time Machine server should be more stable</li>
  <li>Silence - our home server sits in our lounge room in the TV cabinet, so silence is important</li>
</ol>

<p>I went with a <a href="https://support.apple.com/en-us/111894">Mac mini M1</a> with 8GB RAM, 256GB SSD and 2 Thunderbolt 3 ports from eBay for $450 AUD. Not at lot of RAM and storage to be sure, but the downside of a second hand Mac is that these are still way overpriced, even with the release of the latest M4 Mac. If you’re looking for a second hand Mac mini on eBay, it pays to be patient and find one at the right price.</p>

<p>Thankfully, mine arrived in the original box, with no marks or scratches, and in good working order.</p>

<h2 id="set-up-a-mac-mini-as-a-server">Set up a Mac mini as a server</h2>

<h3 id="hardware-setup">Hardware Setup</h3>

<p>For this role, I have the Mac mini connected to the network via ethernet. While not strictly required, it also has an HDMI dummy plug so that it thinks it as a 1080p monitor plugged in allowing it to run headless.</p>

<p>For storage I have an old 256GB SATA SSD plugged in via USB-C because that’s what I had to hand; however, I’ll replace this with a Thunderbolt 3 / USB 4 drive enclosure and a PCIe Gen3 M.2 SSD (there’s no point going to Gen4 because Thunderbolt 3 will be the bottleneck).</p>

<h3 id="first-boot-setup">First Boot Setup</h3>

<p>During the initial macOS setup, I’ve skipped iCloud configuration and not used an Apple ID - I don’t want iCloud downloading Photos, Messages and files etc., to this device. Additionally, FileVault is not enabled so that I can remotely start or reboot the machine.</p>

<p>Initially I was running this without being logged in, but <a href="https://bsky.app/profile/stealthpuppy.com/post/3ldfpzpkcx22i">I found</a> that <code class="language-plaintext highlighter-rouge">/usr/libexec/audiomxd</code> would run with high CPU utilisation like this, so it now logs in automatically to the desktop at boot. This also helps with a couple of apps that I’ll discuss later.</p>

<h3 id="performance-and-power-settings">Performance and Power Settings</h3>

<p>I’ve configured the following settings to either reduce power consumption or improve performance when accessing the Mac remotely. I don’t have hard proof for every setting here, but these logically make sense based on the potential for local or remote access performance.</p>

<ul>
  <li><del>Disable Wi-Fi and Bluetooth - <strong>Off</strong>. I have no need for these on this machine and disabling these will help to save on power consumption</del> I found that after some time, the system would end up not being accessible remotely, even over ethernet, until Wi-Fi was enabled</li>
  <li>System Settings / Energy / Prevent automatic sleeping with the display is off, Wake for network access, Start up automatically after network failure. These settings are enabled to ensure the device does not got to sleep</li>
  <li>System Settings / Accessibility / Display / Reduce motion, Reduce transparency - <strong>On</strong></li>
  <li>System Settings / Appearance / Allow wallpaper tinting in windows - <strong>Off</strong></li>
  <li>System Settings / Apple Intelligence &amp; Siri - <strong>Off</strong>. This feature is certainly not required on a server</li>
  <li>System Settings / Desktop &amp; Dock / Minimise windows using (Scale effect), Animate opening applications - <strong>On</strong></li>
  <li>System Settings / Spotlight / Search results - <strong>Disable all options</strong>. Once the initial indexing is complete, Spotlight probably won’t use too much in terms of resources, but turning it off will eke out that little extra performance or avoid performance issues.</li>
</ul>

<p>Spotlight can be completely disabled with <code class="language-plaintext highlighter-rouge">sudo mdutil -v -E -i off /</code>. Keep in mind that this will likely reduce the effectiveness of searching on mounted shares from remote machines.</p>

<ul>
  <li>System Settings / Wallpaper / Choose a solid colour</li>
  <li>System Settings / Notifications / Show previews (<strong>Never</strong>), Allow notifications when the display is sleeping (<strong>Off</strong>), Allow notifications when the screen is locked (<strong>Off</strong>)</li>
  <li>System Settings / Sound / Play sound on startup, Play user interface sound effects - <strong>Off</strong>. The Mac mini has a built in speaker, but doesn’t need to be making sound in the lounge room</li>
  <li>System Settings / Lock Screen / Start Screen Saver when inactive (<strong>Never</strong>), Turn display off when inactive (<strong>For 5 minutes</strong>), Require password after screen saver begins or display is turned off (<strong>Never</strong>), Show large clock (<strong>Never</strong>) - these settings make sure that the screen saver won’t kick in to consume CPU, but the screen, even with the HDMI headless adapter, will turn off to reduce power consumption</li>
  <li>System Settings / Game Center (<strong>Off</strong>) - I won’t be gaming on this machine</li>
</ul>

<h3 id="sharing-settings">Sharing Settings</h3>

<p>Here’s how I’ve configured <a href="https://support.apple.com/en-au/guide/mac-mini/apd05a94454f/mac">sharing settings</a> in macOS:</p>

<ul>
  <li>System Settings / General / Sharing / File Sharing - <strong>On</strong>. I don’t need large amounts of remote file storage, but this is for convenience</li>
  <li>System Settings / General / Sharing / Media Sharing - <strong>Home Sharing</strong>. I’ve copied my music library to this device and this enables remote sharing from the Apple TV etc.</li>
  <li>System Settings / General / Sharing / Screen Sharing - <strong>On</strong>. This allows remote access to the device via the Screen Sharing app</li>
  <li>System Settings / General / Sharing / Content Caching - Storage (cache location is on an external drive), 
  Clients / Devices using the same public IP address, use only public IP address.</li>
</ul>

<p>Content Cache stats aren’t fantastic just yet, but over time this will increase. On another machine I’ve seen this at around 45GB cached.</p>

<p><img src="/media/2024/12/AssetCacheManagerUtil.png" alt="AssetCacheManagerUtil status" /></p>

<ul>
  <li>System Settings / General / Sharing / Remote Login - <strong>On</strong>. This enables SSH access to perform simple remote management tasks</li>
  <li>System Settings / General / Software Update / Automatic Updates - Download new updates when available, Install macOS updates, Install application updates from the App Store, Install Security Responses and system files - <strong>On</strong></li>
</ul>

<h3 id="homebrew">Homebrew</h3>

<p>Before installing any software, I’ve installed <a href="https://brew.sh">Homebrew</a>, giving me a package manager for macOS that I can use at the command line.</p>

<p>To make updating packages with Homebrew simpler, I’ve added this alias to my <code class="language-plaintext highlighter-rouge">.zshrc</code> file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">alias </span><span class="nv">drink</span><span class="o">=</span><span class="s2">"brew update &amp;&amp; brew upgrade &amp;&amp; brew cleanup"</span>
</code></pre></div></div>

<h3 id="dns-filtering-with-adguard-home">DNS filtering with AdGuard Home</h3>

<p>Installing AdGuard Home on macOS is straight-forward - I followed the <a href="https://github.com/AdguardTeam/AdGuardHome?tab=readme-ov-file#automated-install-linux-and-mac">automated install instructions</a> to install directly onto macOS (i.e. no Docker etc.).</p>

<p>Before setup, I’ve configured external DNS servers in macOS to be able to complete the download and install of AdGuard, then post setup, the device is using my router as the DNS server, which is how all devices on my network are configured. The router then points to AdGuard for all DNS services.</p>

<h3 id="extending-apple-homekit-with-homebridge"><del>Extending Apple HomeKit with HomeBridge</del></h3>

<p class="note">I have replaced Homebridge with Home Assistant - I had too many issues with Homebridge and Home Assistant supports Homekit integration, so I have something more stable. I’ve installed the Home Assistant Operating System in a VM <a href="https://community.home-assistant.io/t/guide-home-assistant-on-apple-silicon-mac-using-ha-os-aarch64-image/444785">using UTM</a>.</p>

<p>The install instructions for <a href="https://github.com/homebridge/homebridge/wiki/Install-Homebridge-on-macOS">HomeBridge on macOS</a> are easy to follow; however, HomeBridge requires Node and that’s more complex to install correctly.</p>

<p>I’ve found the best way to install Node on macOS, is to first install nvm via Homebrew (<code class="language-plaintext highlighter-rouge">brew install nvm</code>), then install the latest LTS version of Node via nvm (rather than installing Node directly via Homebrew). To install the latest Node LTS, I’ve useD:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nvm <span class="nb">install </span>v22.12.0
</code></pre></div></div>

<p>Two things I’ve found with Homebridge on macOS:</p>

<ol>
  <li>I couldn’t get HomeKit to discover the HomeBridge child bridges, so I’ve not used child bridges (just the base Homebridge bridge)</li>
  <li>To get discovery to work correctly, I’ve had to add the <code class="language-plaintext highlighter-rouge">mdns</code> property with the <code class="language-plaintext highlighter-rouge">interface</code> value matching the IP address of the listener, as detailed in <a href="https://github.com/homebridge/homebridge/issues/1957#issuecomment-410505653">this issue</a>:</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"bridge"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Homebridge 2850"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"username"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0E:21:D0:DB:28:51"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">51748</span><span class="p">,</span><span class="w">
        </span><span class="nl">"pin"</span><span class="p">:</span><span class="w"> </span><span class="s2">"743-73-994"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"advertiser"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ciao"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"mdns"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"interface"</span><span class="p">:</span><span class="w"> </span><span class="s2">"192.168.1.4"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"accessories"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span></code></pre></div></div>

<h3 id="remote-hardware-monitoring-with-istatistica">Remote hardware monitoring with iStatistica</h3>

<p>I’ve been using <a href="https://www.imagetasks.com/istatistica/">iStatistic Pro</a> for performance monitoring (CPU, RAM, temps etc.) for some time and this application has a web access portal to view stats. For this app to run, a user needs to be signed into the console of the machine.</p>

<p><img src="/media/2024/12/iStatisticaWebAccess.jpeg" alt="iStatistic Pro web access" /></p>

<h3 id="serving-up-media-with-plex-server">Serving up media with Plex Server</h3>

<p>Installing and configuring Plex Server is very simple - just follow the <a href="https://support.plex.tv/articles/200288586-installation/#toc-1">install instructions</a>. Like iStatistica, Plex Server requires a user to be signed into the console for the app to run.</p>

<p>Plex Server can be installed with Homebrew via this command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>plex-media-server
</code></pre></div></div>

<h3 id="download-torrents-with-transmission">Download torrents with Transmission</h3>

<p><a href="https://transmissionbt.com/download.html">Transmission</a> is my typical go-to for torrent downloads on macOS. It includes a remote web access interface for adding and monitoring downloads. Just like iStatistica and Plex Server, it too requires a user to be signed into the console for the app to run.</p>

<p>One issue I’ve found with the remote access interface, is that I need to access it via the IP address rather than the host name for the app to work.</p>

<h2 id="usage-and-observations">Usage and Observations</h2>

<p>I’ve only been running this server for a week, but so far it performs really well.</p>

<ul>
  <li>CPU utilisation sits at around <strong>3-5%</strong> for normal operation. This increases for various activities including installing updates, Plex Server doing transcoding, etc. CPU does also increase when connecting to the server via Screen Sharing, so I don’t keep a session connected for too long</li>
  <li>CPU, GPU, etc., temperatures stay cool, with the CPU barely getting above <strong>30C</strong></li>
  <li>RAM usage is typically around <strong>2.6 - 2.8GB</strong> with all services running and <a href="https://support.apple.com/en-au/guide/activity-monitor/actmntr1004/mac">Memory Pressure</a> at around 49%. Most importantly, swap is at 0 bytes. For these services I’m running right now, 8GB of RAM looks to be plenty; however, at 16GB RAM model of the Mac mini would provide plenty of future capacity - <a href="https://support.apple.com/en-au/guide/activity-monitor/actmntr34865/mac">Check if your Mac needs more RAM in Activity Monitor</a></li>
  <li>Disk space should be OK for this device - 256GB capacity for the primary OS disk isn’t a lot these days; however, for this device specifically, I’m keeping used space on the OS disk to a minimum by offloading to external storage</li>
  <li>Power consumption is great at around <strong>6W</strong> when idle. This increases to around 7W when watching a 4K video via Plex, and I’ve seen this peak at around 10W. I’m really happy with this power consumption for a device that’s going to be on 24/7 - this replaces the 12-14W the Intel NUC was using at idle</li>
</ul>

<p>So is a Mac mini suitable as a home server? For me the answer is certainly Yes. This is primarily due to having a good number of Apple devices at home that can take advantage of Apple specific features including Content Caching and Time Machine. The low power consumption is excellent for the number of services that it’s capable of running.</p>

<p>While it’s not as efficient as a Raspberry Pi or as flexible as a customised x86 machine, it’s ended up being right where it needs to be for my purposes.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Hardware"/><category term="Hardware"/><category term="Performance"/><summary type="html"><![CDATA[Setting up macOS on a Mac mini M1 as a home server. macOS isn't built to run as a server, but with a few tweaks you can get it to run quite well.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/macmini/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/macmini/image.jpg"/></entry><entry><title type="html">Validate UAT Images with Azure Pipelines and Pester</title><link href="https://stealthpuppy.com/vdi-uat-testing-azure-pipelines/" rel="alternate" title="Validate UAT Images with Azure Pipelines and Pester" type="text/html"/><published>2023-12-20T00:03:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/user-acceptance-testing-for-vdi-with-azure-devops</id><content type="html" xml:base="https://stealthpuppy.com/vdi-uat-testing-azure-pipelines/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#pester--azure-pipelines" id="markdown-toc-pester--azure-pipelines">Pester + Azure Pipelines</a></li>
  <li><a href="#testing-with-pester" id="markdown-toc-testing-with-pester">Testing with Pester</a>    <ul>
      <li><a href="#single-application-test" id="markdown-toc-single-application-test">Single Application Test</a></li>
      <li><a href="#multiple-application-tests" id="markdown-toc-multiple-application-tests">Multiple Application Tests</a>        <ul>
          <li><a href="#input-file" id="markdown-toc-input-file">Input File</a></li>
        </ul>
      </li>
    </ul>
  </li>
  <li><a href="#azure-pipelines" id="markdown-toc-azure-pipelines">Azure Pipelines</a>    <ul>
      <li><a href="#agent-pool" id="markdown-toc-agent-pool">Agent Pool</a></li>
      <li><a href="#pipeline" id="markdown-toc-pipeline">Pipeline</a></li>
      <li><a href="#installing-the-azure-pipelines-agent" id="markdown-toc-installing-the-azure-pipelines-agent">Installing the Azure Pipelines agent</a></li>
      <li><a href="#running-the-pipeline" id="markdown-toc-running-the-pipeline">Running the Pipeline</a></li>
    </ul>
  </li>
  <li><a href="#results" id="markdown-toc-results">Results</a></li>
  <li><a href="#getting-started" id="markdown-toc-getting-started">Getting Started</a></li>
</ul>

<p>As with any desktop environment, virtual desktops undergo regular change - monthly OS and application updates, new applications, and configurations all add to the variation. Change must be managed and should be well tested to ensure business services are not impacted with a new or updated image.</p>

<p>Pooled virtual desktops that are deployed from a gold image are useful for managing change at scale - the user environment is separate from the desktop and users can connect to any available desktop in a pool, so all virtual machines must run the same image.</p>

<p>The gold image build and change process can be automated on any platform and version of Windows, but automation can be a time consuming process. Investing in an image automation process will save your bacon when it counts.</p>

<p>A management and validation process is required to manage an image and this process will look similar to this:</p>

<pre><code class="language-mermaid">flowchart LR
    A[Build]
    A --&gt; B[Validate]
    B --&gt; C[UAT]
    C --&gt; D[Deploy]
</code></pre>

<p>When a gold image is updated and deployed, most organisations will rely on manual user acceptance testing before promoting that image into production. Adding automated testing in the VALIDATE phase ensures you can capture things users won’t, or speed the mundane task of manually validating your images.</p>

<p>There are several commercial solutions that can automate application testing (for example, <a href="https://www.rimo3.com/">Rimo3</a>), but you may want to augment these with additional valiatdation tests. For example, testing that your image includes the intended applications, application versions, files, registry settings or service status.</p>

<h2 id="pester--azure-pipelines">Pester + Azure Pipelines</h2>

<p>With Pester and Azure Pipelines, we can create an image test framework that runs on a target virtual machine and generates reports to track the results. This approach uses a standard and well supported test framework, along with Azure Pipelines which is available in most enterprises (although, you could replace Azure Pipelines with just about any CI/CD service).</p>

<p>Here’s a look at what we’re going to build:</p>

<p><a href="/media/2023/12/PesterTestsPassed100.jpeg"><img src="/media/2023/12/PesterTestsPassed100.jpeg" alt="A screenshot of a successful Azure Pipeline run" /></a></p>

<p class="figcaption">Azure Pipelines result with 100% of tests passed.</p>

<p>Building this solution will involve a few components:</p>

<ul>
  <li>PowerShell and <a href="https://pester.dev">Pester</a>, the testing framework in which we can write our tests</li>
  <li><a href="https://stealthpuppy.com/evergreen/">Evergreen</a> to provide application version numbers - regardless of how you install applications, Evergreen enables you to query an image to determine whether it’s running the latest version of an application</li>
  <li>JSON to define our tests so that we don’t have to hard code all tests in Pester</li>
  <li>Azure DevOps to store the tests and code</li>
  <li>Azure Pipelines and <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/agents?view=azure-devops&amp;tabs=yaml%2Cbrowser#install">self-hosted agents</a> to run tests against our UAT images</li>
</ul>

<p>Note that in this example, I’m using Azure Virtual Desktop and Nerdio Manager to create images and run session hosts; however, this approach will work with any VDI solution including those deployed on-premises or in a public cloud platform.</p>

<h2 id="testing-with-pester">Testing with Pester</h2>

<h3 id="single-application-test">Single Application Test</h3>

<p>Pester can be used to perform an application configuration test. Here’s a simple example of using Pester to ensure that the default home page for Microsoft Edge has been set in the image.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Describe</span><span class="w"> </span><span class="s2">"Microsoft Edge"</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Context</span><span class="w"> </span><span class="s2">"Application preferences"</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have written the correct content to master_preferences"</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">${Env:ProgramFiles(x86)}</span><span class="s2">\Microsoft\Edge\Application\master_preferences"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">homepage</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeExactly</span><span class="w"> </span><span class="s2">"https://www.microsoft365.com"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This will generate a pass / fail result allowing us to determine whether the image has the correct configuration for Edge. If it fails, we know our image isn’t ready for UAT and can’t be pushed into production.</p>

<p>Additional tests can be written just like this example. Storing application tests in one Pester file per application can be a way to ensure those tests are highly portable between image configurations.</p>

<h3 id="multiple-application-tests">Multiple Application Tests</h3>

<p>Our tests will be more scalable if a single script can take input that defines tests for multiple applications. We can scale out our test results for each application without having to write additional code.</p>

<p><a href="/media/2023/12/Pester02.png"><img src="/media/2023/12/Pester02.png" alt="Pester testing of applications" /></a></p>

<p class="figcaption">Pester file to run tests against a set of applications.</p>

<p>For example, here’s a Pester block that takes input to test various properties of an application:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Context</span><span class="w"> </span><span class="s2">"Application configuration tests"</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Should be the current version or better"</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="p">[</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$Installed</span><span class="o">.</span><span class="nf">Version</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeGreaterOrEqual</span><span class="w"> </span><span class="p">([</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$Latest</span><span class="o">.</span><span class="nf">Version</span><span class="p">)</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have application file installed: &lt;_&gt;"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$FilesExist</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-Exist</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have shortcut deleted or removed: &lt;_&gt;"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$ShortcutsNotExist</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-Exist</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have the service disabled: &lt;_&gt;"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$ServicesDisabled</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="p">(</span><span class="n">Get-Service</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="bp">$_</span><span class="p">)</span><span class="o">.</span><span class="nf">StartType</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-Be</span><span class="w"> </span><span class="s2">"Disabled"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This block does a few things:</p>

<ol>
  <li>Compares the installed version of the application against the latest version returned by Evergreen</li>
  <li>Tests that an array of files exists (i.e. key files exist in the expected locations)</li>
  <li>Tests that an array of shortcuts do not exist (to test that an install script has removed shortcuts)</li>
  <li>Tests that an array of services has been disabled (some applications include services that are recommended to be disabled in VDI images)</li>
</ol>

<h4 id="input-file">Input File</h4>

<p>The Pester script takes input via a JSON file that describes the tests for applications in our image. The script and the input file could be extended to test practically anything in the image. The example input file below lists tests for 3 applications to determine that the application is installed, is current, has files in the expected directories, and services in a disabled state:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MicrosoftFSLogixApps"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">MicrosoftFSLogixApps</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Channel -eq </span><span class="se">\"</span><span class="s2">Production</span><span class="se">\"</span><span class="s2"> } | Select-Object -First 1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft FSLogix Apps"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frx.exe"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">ConfigurationTool.exe"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frxcontext.exe"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frxshell.exe"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">ProgramData</span><span class="se">\\</span><span class="s2">Microsoft</span><span class="se">\\</span><span class="s2">Windows</span><span class="se">\\</span><span class="s2">Start Menu</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">FSLogix Apps Online Help.lnk"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MicrosoftEdge"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">MicrosoftEdge</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Architecture -eq </span><span class="se">\"</span><span class="s2">x64</span><span class="se">\"</span><span class="s2"> -and $_.Channel -eq </span><span class="se">\"</span><span class="s2">Stable</span><span class="se">\"</span><span class="s2"> -and $_.Release -eq </span><span class="se">\"</span><span class="s2">Enterprise</span><span class="se">\"</span><span class="s2"> } | Sort-Object -Property </span><span class="se">\"</span><span class="s2">Version</span><span class="se">\"</span><span class="s2"> -Descending | Select-Object -First 1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"(^Microsoft Edge$)"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files (x86)</span><span class="se">\\</span><span class="s2">Microsoft</span><span class="se">\\</span><span class="s2">Edge</span><span class="se">\\</span><span class="s2">Application</span><span class="se">\\</span><span class="s2">master_preferences"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Users</span><span class="se">\\</span><span class="s2">Public</span><span class="se">\\</span><span class="s2">Desktop</span><span class="se">\\</span><span class="s2">Microsoft Edge*.lnk"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AdobeAcrobatReaderDC"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">AdobeAcrobatReaderDC</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Language -eq </span><span class="se">\"</span><span class="s2">MUI</span><span class="se">\"</span><span class="s2"> -and $_.Architecture -eq </span><span class="se">\"</span><span class="s2">x64</span><span class="se">\"</span><span class="s2"> } | Select-Object -First 1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Adobe Acrobat.*64-bit"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">Adobe</span><span class="se">\\</span><span class="s2">Acrobat DC</span><span class="se">\\</span><span class="s2">Acrobat</span><span class="se">\\</span><span class="s2">Acrobat.exe"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">Adobe</span><span class="se">\\</span><span class="s2">Acrobat DC</span><span class="se">\\</span><span class="s2">Acrobat</span><span class="se">\\</span><span class="s2">AdobeCollabSync.exe"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
        </span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"AdobeARMservice"</span><span class="w">
        </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h2 id="azure-pipelines">Azure Pipelines</h2>

<p>Azure Pipelines will run the tests against our target image via the Pipelines agent. To run our tests, we need two things - a pipeline, and an agent pool with self-hosted agents.</p>

<p><a href="/media/2023/12/SelfHostedAgent.jpeg"><img src="/media/2023/12/SelfHostedAgent.jpeg" alt="Self-hosted agent" /></a></p>

<p class="figcaption">An Azure Virtual Desktop session host as a self-hosted agent for Azure Pipelines.</p>

<h3 id="agent-pool">Agent Pool</h3>

<p>I won’t cover creating an agent pool in detail here, instead refer to the documentation - <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/pools-queues?view=azure-devops&amp;tabs=yaml%2Cbrowser">Create and manage agent pools</a>. The agent pool name is important though as it needs to be referred to in the pipeline file and the agent install script.</p>

<h3 id="pipeline">Pipeline</h3>

<p>The pipeline configuration defines how the pipeline will run and post results back to DevOps for reporting. Below is a code listing of the pipeline file which does the following:</p>

<ul>
  <li>Install Pester - note that Evergreen is also required, so you will need to add that to the list of installed modules, if Evergreen is not already installed into the image</li>
  <li>Run the tests stored in the <code class="language-plaintext highlighter-rouge">tests</code> directory</li>
  <li>Post the test results back to DevOps for reporting - this is displayed as the pass / fail status</li>
  <li>Gather a list of the installed applications and post that back to DevOps as well. This information will enable you to track application versions across images</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">pool</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Azure</span><span class="nv"> </span><span class="s">Virtual</span><span class="nv"> </span><span class="s">Desktop</span><span class="nv"> </span><span class="s">aue'</span>

<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">powershell</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">$params = @{</span>
      <span class="s">Name               = "Pester"</span>
      <span class="s">SkipPublisherCheck = $true</span>
      <span class="s">Force              = $true</span>
      <span class="s">ErrorAction        = "Stop"</span>
    <span class="s">}</span>
    <span class="s">Install-Module @params</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">pester</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Install</span><span class="nv"> </span><span class="s">Pester'</span>
  <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
  <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">continue</span>
  <span class="na">continueOnError</span><span class="pi">:</span> <span class="kc">false</span>

<span class="pi">-</span> <span class="na">powershell</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">Import-Module -Name "Pester" -Force -ErrorAction "Stop"</span>
    <span class="s">$Config = New-PesterConfiguration</span>
    <span class="s">$Config.Run.Path = "$(build.sourcesDirectory)\tests"</span>
    <span class="s">$Config.Run.PassThru = $true</span>
    <span class="s">$Config.TestResult.Enabled = $true</span>
    <span class="s">$Config.TestResult.OutputFormat = "NUnitXml"</span>
    <span class="s">$Config.TestResult.OutputPath = "$(build.sourcesDirectory)\TestResults.xml"</span>
    <span class="s">$Config.Output.Verbosity = "Detailed"</span>
    <span class="s">Invoke-Pester -Configuration $Config</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">test</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Validate</span><span class="nv"> </span><span class="s">installed</span><span class="nv"> </span><span class="s">apps'</span>
  <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
  <span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">continue</span>
  <span class="na">continueOnError</span><span class="pi">:</span> <span class="kc">true</span>

<span class="pi">-</span> <span class="na">publish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">InstalledApplications.csv"</span>
  <span class="na">artifact</span><span class="pi">:</span> <span class="s">InstalledApplications</span>
  <span class="na">continueOnError</span><span class="pi">:</span> <span class="kc">true</span>

<span class="pi">-</span> <span class="na">publish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">TestResults.xml"</span>
  <span class="na">artifact</span><span class="pi">:</span> <span class="s">TestResults</span>
  <span class="na">continueOnError</span><span class="pi">:</span> <span class="kc">true</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishTestResults@2</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">testResultsFormat</span><span class="pi">:</span> <span class="s2">"</span><span class="s">NUnit"</span>
    <span class="na">testResultsFiles</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">TestResults.xml"</span>
    <span class="na">failTaskOnFailedTests</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">testRunTitle</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Publish</span><span class="nv"> </span><span class="s">Pester</span><span class="nv"> </span><span class="s">results"</span>
</code></pre></div></div>

<h3 id="installing-the-azure-pipelines-agent">Installing the Azure Pipelines agent</h3>

<p>The Azure Pipelines agent is required to add a virtual machine as a self-hosted agent. The Azure Pipelines agent should not be built into your gold image - it should be installed into a target session host after it’s been deployed.</p>

<p>In my Azure Virtual Desktop environment, Nerdio Manager is used to create images, deploy session hosts, and manage scaling. <a href="https://nmw.zendesk.com/hc/en-us/articles/4731662951447-Scripted-Actions-Overview">Nerdio Manager Scripted Actions</a> can be run when a session host is created. This enables us to install the Azure Pipelines agent on session hosts in a UAT host pool when the session host is created.</p>

<p>Nerdio Manager also makes it simple to pass secure strings into the agent install script at runtime. The install script uses Evergreen to find the latest version of the Azure Pipelines agent, download, unpack and install the agent, including creating a local user account required by the agent and setting Azure DevOps settings.</p>

<p>The install script requires the following variables to be set in <a href="https://nmw.zendesk.com/hc/en-us/articles/4731671517335-Scripted-Actions-Global-Secure-Variables">Scripted Actions Global Secure Variables</a></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">DevOpsUrl</code> - the URL to our DevOps organisation</li>
  <li><code class="language-plaintext highlighter-rouge">DevOpsPat</code> - the <a href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate">Personal Access Token</a> used to authenticate to Azure DevOps</li>
  <li><code class="language-plaintext highlighter-rouge">DevOpsPool</code> - the agent pool that the self-hosted agent will register with</li>
  <li><code class="language-plaintext highlighter-rouge">DevOpsUser</code> - the local user account name the install script will create. This account will be added to the local administrators group on the target session host; therefore, I recommend you do not deploy this to production workloads</li>
  <li><code class="language-plaintext highlighter-rouge">DevOpsPassword</code> - the password for the local user account</li>
</ul>

<p><a href="/media/2023/12/NerdioSecureVariables.jpeg"><img src="/media/2023/12/NerdioSecureVariables.jpeg" alt="Nerdio Manager Secure Variables for the Azure Pipelines agent" /></a></p>

<p class="figcaption">Nerdio Manager Secure Variables for the Azure Pipelines agent.</p>

<p>The block below lists the code for the installing the Azure Pipelines agent. This uses Evergreen to find the latest version and download into the session host:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#description: Installs the Microsoft Azure Pipelines agent to enable automated testing via Azure Pipelines. Do not run on production session hosts.</span><span class="w">
</span><span class="c">#execution mode: Combined</span><span class="w">
</span><span class="c">#tags: Evergreen, Testing, DevOps</span><span class="w">

</span><span class="c"># Check that the required variables have been set in Nerdio Manager</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$Value</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="s2">"DevOpsUrl"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPool"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsUser"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPassword"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$null</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nv">$Value</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">throw</span><span class="w"> </span><span class="s2">"</span><span class="nv">$Value</span><span class="s2"> is null"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># Download and extract</span><span class="w">
</span><span class="p">[</span><span class="n">System.String</span><span class="p">]</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">SystemDrive</span><span class="s2">\agents"</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="s2">"Directory"</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
</span><span class="nx">Import-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"Evergreen"</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="nv">$App</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-EvergreenApp</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"MicrosoftAzurePipelinesAgent"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Architecture</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"x64"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">1</span><span class="w">
</span><span class="nv">$OutFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Save-EvergreenApp</span><span class="w"> </span><span class="nt">-InputObject</span><span class="w"> </span><span class="nv">$App</span><span class="w"> </span><span class="nt">-CustomPath</span><span class="w"> </span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">Temp</span><span class="w"> </span><span class="nt">-WarningAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w">
</span><span class="n">Expand-Archive</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$OutFile</span><span class="o">.</span><span class="nf">FullName</span><span class="w"> </span><span class="nt">-DestinationPath</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="n">Push-Location</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Path</span><span class="w">

</span><span class="c"># Create the local account that the DevOps Pipelines agent service will run under</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Name</span><span class="w">                     </span><span class="o">=</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="err">.</span><span class="nx">DevOpsUser</span><span class="w">
    </span><span class="nx">Password</span><span class="w">                 </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">ConvertTo</span><span class="err">-</span><span class="nx">SecureString</span><span class="w"> </span><span class="err">-</span><span class="nx">String</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="err">.</span><span class="nx">DevOpsPassword</span><span class="w"> </span><span class="err">-</span><span class="nx">AsPlainText</span><span class="w"> </span><span class="err">-</span><span class="nx">Force</span><span class="err">)</span><span class="w">
    </span><span class="nx">Description</span><span class="w">              </span><span class="o">=</span><span class="w"> </span><span class="s2">"Azure Pipelines agent service for elevated exec."</span><span class="w">
    </span><span class="nx">UserMayNotChangePassword</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">Confirm</span><span class="w">                  </span><span class="o">=</span><span class="w"> </span><span class="bp">$false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">New-LocalUser</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span><span class="n">Add-LocalGroupMember</span><span class="w"> </span><span class="nt">-Group</span><span class="w"> </span><span class="s2">"Administrators"</span><span class="w"> </span><span class="nt">-Member</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUser</span><span class="w">

</span><span class="c"># Agent install options</span><span class="w">
</span><span class="nv">$Options</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"--unattended
        --url </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUrl</span><span class="si">)</span><span class="se">`"</span><span class="s2">
        --auth pat
        --token </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPat</span><span class="si">)</span><span class="se">`"</span><span class="s2">
        --pool </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPool</span><span class="si">)</span><span class="se">`"</span><span class="s2">
        --agent </span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">COMPUTERNAME</span><span class="s2">
        --runAsService
        --windowsLogonAccount </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUser</span><span class="si">)</span><span class="se">`"</span><span class="s2">
        --windowsLogonPassword </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPassword</span><span class="si">)</span><span class="se">`"</span><span class="s2">
        --replace"</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">FilePath</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$Path</span><span class="s2">\config.cmd"</span><span class="w">
    </span><span class="nx">ArgumentList</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$(</span><span class="nv">$Options</span><span class="w"> </span><span class="err">-</span><span class="nx">replace</span><span class="w"> </span><span class="s2">"\s+"</span><span class="p">,</span><span class="w"> </span><span class="s2">" "</span><span class="err">)</span><span class="w">
    </span><span class="nx">Wait</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">NoNewWindow</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
    </span><span class="nx">PassThru</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">Start-Process</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>

<p>This script should be imported into Nerdio Manager as a scripted action, and added to the VM Deployment settings on the host pool, so that the agent is installed when a session host is deployed.</p>

<h3 id="running-the-pipeline">Running the Pipeline</h3>

<p>When you have a new version of a gold image, deploy the image to a UAT host pool, then manually run the Azure Pipeline to validate the image.</p>

<p>Right now, it’s a manual process to start the pipeline because we don’t have a self-hosted agent running until a new image has been deployed. With some additional configuration this could be automated so the pipeline kicks off when a new image is deployed. The pipeline could be run via an API call to Azure DevOps when an a new session host is deployed via some external orchestration host.</p>

<p><a href="/media/2023/12/RunPipeline.jpeg"><img src="/media/2023/12/RunPipeline.jpeg" alt="Starting the pipeline" /></a></p>

<p class="figcaption">Manually starting the pipeline on a new image.</p>

<h2 id="results">Results</h2>

<p>A pipeline run only takes a few seconds and the results can be tracked across runs. Update your retention settings to retain reports for longer.</p>

<p><a href="/media/2023/12/PesterTestsPassed97.jpeg"><img src="/media/2023/12/PesterTestsPassed97.jpeg" alt="A screenshot of a failed Azure Pipeline run" /></a></p>

<p class="figcaption">Azure Pipelines result showing passed and failed tests.</p>

<p>Artifacts are stored on the pipeline run - <code class="language-plaintext highlighter-rouge">InstalledApplications.csv</code> will help keep track of installed applications and versions.</p>

<p><a href="/media/2023/12/Artifacts.jpeg"><img src="/media/2023/12/Artifacts.jpeg" alt="Artifact objects " /></a></p>

<p class="figcaption">Artifacts stored on the pipeline.</p>

<h2 id="getting-started">Getting Started</h2>

<p>In this article, I’ve assumed you have an understanding of PowerShell, Pester, and Azure DevOps / Pipelines. If any of the concepts aren’t clear, comment below, and I’ll might be able to expand on some details in future articles.</p>

<p>To get the most out of this approach, I highly recommended that you are also automating the build of new gold images. The framework outlined in this article could also be run at the end of an automated build; however, even if you are manually building images, this approach can assist in validation.</p>

<p>To get started with this test and validation solution, you can fork the code in my <code class="language-plaintext highlighter-rouge">vdi-uat</code> repository here: <a href="https://github.com/aaronparker/vdi-uat">https://github.com/aaronparker/vdi-uat</a>.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Microsoft"/><category term="AVD"/><category term="Automation"/><category term="Testing"/><summary type="html"><![CDATA[Automated validation of VDI images for user acceptance testing with Azure Pipelines and self-hosted agents running Pester to perform automated tests with Evergreen.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/test/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/test/image.jpg"/></entry><entry><title type="html">Setting Up a Local Environment for IntuneCD</title><link href="https://stealthpuppy.com/intunecd-local-environment/" rel="alternate" title="Setting Up a Local Environment for IntuneCD" type="text/html"/><published>2023-07-11T14:24:00+00:00</published><updated>2026-04-03T02:01:16+00:00</updated><id>https://stealthpuppy.com/using-intunecd-interactively</id><content type="html" xml:base="https://stealthpuppy.com/intunecd-local-environment/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#running-intunecd-locally" id="markdown-toc-running-intunecd-locally">Running IntuneCD Locally</a>    <ul>
      <li><a href="#authentication" id="markdown-toc-authentication">Authentication</a></li>
      <li><a href="#prerequisites" id="markdown-toc-prerequisites">Prerequisites</a></li>
    </ul>
  </li>
  <li><a href="#platform-configuration" id="markdown-toc-platform-configuration">Platform Configuration</a>    <ul>
      <li><a href="#windows" id="markdown-toc-windows">Windows</a></li>
      <li><a href="#wsl2-and-linux" id="markdown-toc-wsl2-and-linux">WSL2 and Linux</a></li>
      <li><a href="#macos" id="markdown-toc-macos">macOS</a></li>
    </ul>
  </li>
  <li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>

<p>I’ve previously written about using IntuneCD in a GitHub or Azure DevOps pipeline to backup and document an Intune tenant in these articles:</p>

<ul>
  <li><a href="https://stealthpuppy.com/automate-intune-documentation-github/">Automate Microsoft Intune As-Built Documentation on GitHub</a></li>
  <li><a href="https://stealthpuppy.com/automate-intune-documentation-azure/">Automate Microsoft Intune As-Built Documentation on Azure DevOps</a></li>
</ul>

<p>The approach uses Linux runners hosted by GitHub or Microsoft, thus the platform configuration is already taken care of (Python, Node.js, and dependencies etc.). I do often find myself using IntuneCD locally to backup a tenant and produce an as-built document - running locally requires installing the prerequisites which can be a bit of fun (or hair pulling) depending on your target platform.</p>

<p>In this article, I’ll cover the steps to install and configure the prerequisites for running IntuneCD on Windows, Windows Subsystem for Linux (WSL2), Linux and macOS. <strong>Note</strong> - these are the install steps that have worked for me on each of these platforms. Your mileage may vary.</p>

<h2 id="running-intunecd-locally">Running IntuneCD Locally</h2>

<p>The scripts below can be used to backup your Intune tenant and create a new as-built document in PDF and HTML formats. This approach is useful where you might not want to go as far as creating a pipeline to automate the entire process. These scripts can be used for an adhoc approach to backup and document generation.</p>

<p>Below is the script in PowerShell format which has been tested on Windows. You can find the original source in my <a href="https://github.com/aaronparker/intune-backup-template">template repository on GitHub</a>.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">New-Item</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="s2">"Directory"</span><span class="w">
</span><span class="n">IntuneCD-startbackup</span><span class="w"> </span><span class="nt">--mode</span><span class="o">=</span><span class="mi">1</span><span class="w"> </span><span class="nt">--output</span><span class="o">=</span><span class="n">json</span><span class="w"> </span><span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">--localauth</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\auth.json"</span><span class="w">

</span><span class="c"># Generate the as-built document in markdown</span><span class="w">
</span><span class="nv">$Auth</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\auth.json"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$INTRO</span><span class="o">=</span><span class="s2">"Intune backup and documentation generated locally. &lt;img align=</span><span class="se">`"</span><span class="s2">right</span><span class="se">`"</span><span class="s2"> width=</span><span class="se">`"</span><span class="s2">96</span><span class="se">`"</span><span class="s2"> height=</span><span class="se">`"</span><span class="s2">96</span><span class="se">`"</span><span class="s2"> src=</span><span class="se">`"</span><span class="s2">./logo.png</span><span class="se">`"</span><span class="s2">&gt;"</span><span class="w">
</span><span class="n">IntuneCD-startdocumentation</span><span class="w"> </span><span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">--outpath</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--tenantname</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nv">$Auth</span><span class="o">.</span><span class="nf">TENANT_NAME</span><span class="si">)</span><span class="s2">"</span><span class="w"> </span><span class="nt">--intro</span><span class="o">=</span><span class="s2">"</span><span class="nv">$INTRO</span><span class="s2">"</span><span class="w">

</span><span class="c"># Generate a PDF document from the as-built markdown</span><span class="w">
</span><span class="n">md-to-pdf</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--config-file</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\md2pdf\pdfconfig.json"</span><span class="w">

</span><span class="c"># Generate a HTML document from the as-built markdown</span><span class="w">
</span><span class="n">md-to-pdf</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--config-file</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\md2pdf\htmlconfig.json"</span><span class="w"> </span><span class="nt">--as-html</span><span class="w">
</span></code></pre></div></div>

<p>Below is the same script in Shell Script format - this has been tested on WSL2, Linux and macOS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span>
IntuneCD-startbackup <span class="nt">--mode</span><span class="o">=</span>1 <span class="nt">--output</span><span class="o">=</span>json <span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span> <span class="nt">--localauth</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/auth.json"</span>

<span class="c"># Generate the as-built document in markdown</span>
<span class="nv">TENANT</span><span class="o">=</span><span class="si">$(</span>jq .params.TENANT_NAME <span class="nv">$PWD</span>/auth.json | <span class="nb">tr</span> <span class="nt">-d</span> <span class="se">\"</span><span class="si">)</span>
<span class="nv">INTRO</span><span class="o">=</span><span class="s2">"Intune backup and documentation generated locally. &lt;img align=</span><span class="se">\"</span><span class="s2">right</span><span class="se">\"</span><span class="s2"> width=</span><span class="se">\"</span><span class="s2">96</span><span class="se">\"</span><span class="s2"> height=</span><span class="se">\"</span><span class="s2">96</span><span class="se">\"</span><span class="s2"> src=</span><span class="se">\"</span><span class="s2">./logo.png</span><span class="se">\"</span><span class="s2">&gt;"</span>
IntuneCD-startdocumentation <span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span> <span class="nt">--outpath</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--tenantname</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TENANT</span><span class="s2">"</span> <span class="nt">--intro</span><span class="o">=</span><span class="s2">"</span><span class="nv">$INTRO</span><span class="s2">"</span>

<span class="c"># Generate a PDF document from the as-built markdown</span>
md-to-pdf <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--config-file</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/md2pdf/pdfconfig.json"</span>

<span class="c"># Generate a HTML document from the as-built markdown</span>
md-to-pdf <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--config-file</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/md2pdf/htmlconfig.json"</span> <span class="nt">--as-html</span>
</code></pre></div></div>

<h3 id="authentication">Authentication</h3>

<p>IntuneCD uses an <a href="https://github.com/almenscorner/IntuneCD/wiki/Authentication">Azure AD app registration to authenticate</a> to the Microsoft Graph API. The script above uses a local JSON file with credentials (see the example below). If your local backup/export of your Intune tenant is hosted in a git repository, make sure you add <code class="language-plaintext highlighter-rouge">auth.json</code> to the <code class="language-plaintext highlighter-rouge">.gitignore</code> file - <a href="https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files">Ignoring files</a>.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"params"</span><span class="p">:{</span><span class="w">
        </span><span class="nl">"TENANT_NAME"</span><span class="p">:</span><span class="w"> </span><span class="s2">"intunetenant.onmicrosoft.com"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"CLIENT_ID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"28f60124-eb81-40e1-b1c4-1bb06c44ec91"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"CLIENT_SECRET"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AsT8Q~j_8MqluNxFi_4TIC8kdXzRdjEwM.tZxcjS"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="prerequisites">Prerequisites</h3>

<p>Running the scripts requires the following dependencies:</p>

<ul>
  <li><a href="https://www.python.org/">Python</a> - IntuneCD is written in Python</li>
  <li><a href="https://nodejs.org/">Node.js</a> - required by md-to-pdf</li>
  <li><a href="https://jqlang.github.io/jq/">jq</a> - this is only required when running the script on WSL2, Linux or macOS. The PowerShell script uses <code class="language-plaintext highlighter-rouge">ConvertFrom-Json</code> instead</li>
</ul>

<h2 id="platform-configuration">Platform Configuration</h2>

<h3 id="windows">Windows</h3>

<p>Use the Windows Package Manager (winget) to install an environment on Windows. Elevate a Terminal window, and run the following winget commands to install Python, NVM for Windows (Node.js version manager), and git for Windows.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Python and NVM for Windows</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Python.Python.3.11</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">CoreyButler.NVMforWindows</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span></code></pre></div></div>

<p>If you’re storing your configuration in git repository you can install git locally. Additionally, install the GitHub CLI will help with <a href="https://cli.github.com/manual/gh_auth_login">authenticating to GitHub</a>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install git for Windows and GitHub CLI</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Git.Git</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Github.cli</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span></code></pre></div></div>

<p>Close and restart an elevated Terminal window and install IntuneCD, Node.js, and md-to-pdf. The Node.js install is using NVM as recommended by Microsoft -  <a href="https://learn.microsoft.com/en-us/windows/dev-environment/javascript/nodejs-on-windows">Install NodeJS on Windows</a>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install IntuneCD, Node.js and md-to-pdf</span><span class="w">
</span><span class="n">pip3</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">IntuneCD</span><span class="w">
</span><span class="n">nvm</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">18.6.1</span><span class="w">
</span><span class="n">npm</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="nt">-g</span><span class="w"> </span><span class="nx">md-to-pdf</span><span class="w">
</span></code></pre></div></div>

<h3 id="wsl2-and-linux">WSL2 and Linux</h3>

<p>The steps for setting up an environment on <a href="https://learn.microsoft.com/en-us/windows/wsl/about">WSL2</a> and Linux will be the same.</p>

<p>If your target platform is Windows, the benefit of WSL2 over Linux is that you don’t need to run an entire virtual machine just to run Linux. Additionally, using WSL2 on Windows instead of natively installing python and Node.js, means that your development environment is containerised within WSL, thus you can remove the entire environment by deleting the WSL instance.</p>

<p>However, you may need to weigh that against wasting hours of your life on getting Linux running that you’ll never get back.</p>

<p>The script below will configure an environment, and assumes you are using WLS2 with Ubuntu or an Ubuntu virtual machine with a minimal installation:</p>

<ul>
  <li>Install required dependencies including <a href="https://jqlang.github.io/jq/">jq</a> with <code class="language-plaintext highlighter-rouge">apt-get</code></li>
  <li><a href="https://brew.sh/">Homebrew</a> this will simplify the installation of additional components including Python. I had issues installing Python with pyenv</li>
  <li>Install Python with Homebrew</li>
  <li>Install IntuneCD</li>
  <li>Install <a href="https://github.com/nvm-sh/nvm">nvm</a> and Node.js</li>
  <li>Install md-to-pdf</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Update the OS and install git, curl and build-essential required for Homebrew</span>
<span class="nb">sudo </span>apt-get update<span class="p">;</span> <span class="nb">sudo </span>apt-get upgrade <span class="nt">-y</span>
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> git curl build-essential jq

<span class="c"># Install Node.js dependencies</span>
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0

<span class="c"># Install Homebrew and Python</span>
/bin/bash <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="si">)</span><span class="s2">"</span>
brew <span class="nb">install </span>python@3.11

<span class="c"># Update pip and install IntuneCD</span>
python3.11 <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">--upgrade</span> pip
pip3 <span class="nb">install </span>IntuneCD

<span class="c"># Install Node.js version manager and Node.js LTS</span>
curl <span class="nt">-o-</span> https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
<span class="nb">export </span><span class="nv">NVM_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.nvm"</span>
<span class="o">[</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="se">\.</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span>
nvm <span class="nb">install</span> <span class="nt">--lts</span>

<span class="c"># Install md-to-pdf</span>
npm i <span class="nt">-g</span> md-to-pdf
</code></pre></div></div>

<h3 id="macos">macOS</h3>

<p>The script below will set up the required dependencies and tools on macOS. This assumes you are using the default zsh shell and will install the following:</p>

<ul>
  <li><a href="https://brew.sh/">Homebrew</a> which is the best package manager for macOS</li>
  <li><a href="https://github.com/pyenv/pyenv">pyenv</a> to simplify the installation of Python. Follow the install instructions to set up pyenv for macOS and zsh</li>
  <li><a href="https://jqlang.github.io/jq/">jq</a> and <a href="https://cli.github.com/manual/gh_auth_login">GitHub CLI</a></li>
  <li>Install Python with pyenv and set the default version</li>
  <li>Install IntuneCD</li>
  <li>Install nvm and Node.js</li>
  <li>Install md-to-pdf</li>
</ul>

<div class="language-zsh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Homebrew, pyenv, and jq</span>
/bin/bash <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="si">)</span><span class="s2">"</span>
brew update
brew <span class="nb">install </span>pyenv jq gh

<span class="c"># Set up the shell environment for pyenv</span>
<span class="nb">echo</span> <span class="s1">'export PYENV_ROOT="$HOME/.pyenv"'</span> <span class="o">&gt;&gt;</span> ~/.zshrc
<span class="nb">echo</span> <span class="s1">'command -v pyenv &gt;/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"'</span> <span class="o">&gt;&gt;</span> ~/.zshrc
<span class="nb">echo</span> <span class="s1">'eval "$(pyenv init -)"'</span> <span class="o">&gt;&gt;</span> ~/.zshrc

<span class="c"># Install Python and set a global version</span>
pyenv <span class="nb">install </span>3.11.4
pyenv global 3.11.4

<span class="c"># Install IntuneCD</span>
pip3 <span class="nb">install </span>IntuneCD

<span class="c"># Install Node.js version manager and Node.js LTS</span>
curl <span class="nt">-o-</span> https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
<span class="nb">export </span><span class="nv">NVM_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.nvm"</span>
<span class="o">[</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="se">\.</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span>
nvm <span class="nb">install</span> <span class="nt">--lts</span>

<span class="c"># Install md-to-pdf</span>
npm i <span class="nt">-g</span> md-to-pdf
</code></pre></div></div>

<h2 id="wrap-up">Wrap Up</h2>

<p>The commands listed this article should enable you to set up your local environment to use with IntuneCD. This includes each of the required dependencies. I’ve tested on Windows 11, macOS 13.4 and Ubuntu 22.04 across multiple devices and virtual machines, so the commands should be well tested and hopefully work for you.</p>

<p>My preferred platforms for using IntuneCD locally are macOS and Windows. Both platforms are simple to configure and starting using IntuneCD to export an Intune configuration and create an as-built document. However, if you prefer Linux the commands listed in this article will assist in configuring that platform.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Microsoft"/><category term="Intune"/><category term="PowerShell"/><category term="Automation"/><summary type="html"><![CDATA[Setting up a local Windows, WSL2, Linux or macOS environment to use IntuneCD to backup and document an Intune tenant.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/intunecd/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/intunecd/image.jpg"/></entry></feed>