<?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-06-09T00:39:36+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">Evergreen Workbench is Generally Available</title><link href="https://stealthpuppy.com/evergreen-workbench-release/" rel="alternate" title="Evergreen Workbench is Generally Available" type="text/html"/><published>2026-06-09T00:00:00+00:00</published><updated>2026-06-09T00:31:26+00:00</updated><id>https://stealthpuppy.com/evergreen-workbench-release</id><content type="html" xml:base="https://stealthpuppy.com/evergreen-workbench-release/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#installing-the-desktop-workbench" id="markdown-toc-installing-the-desktop-workbench">Installing the Desktop Workbench</a></li>
  <li><a href="#apps-view" id="markdown-toc-apps-view">Apps View</a>    <ul>
      <li><a href="#dynamic-filters" id="markdown-toc-dynamic-filters">Dynamic Filters</a></li>
      <li><a href="#favourites-and-column-management" id="markdown-toc-favourites-and-column-management">Favourites and Column Management</a></li>
    </ul>
  </li>
  <li><a href="#download-view" id="markdown-toc-download-view">Download View</a></li>
  <li><a href="#library-view" id="markdown-toc-library-view">Library View</a></li>
  <li><a href="#import-tab" id="markdown-toc-import-tab">Import Tab</a>    <ul>
      <li><a href="#microsoft-intune-win32-apps" id="markdown-toc-microsoft-intune-win32-apps">Microsoft Intune Win32 Apps</a></li>
      <li><a href="#nerdio-manager-shell-apps" id="markdown-toc-nerdio-manager-shell-apps">Nerdio Manager Shell Apps</a></li>
      <li><a href="#microsoft-365-apps" id="markdown-toc-microsoft-365-apps">Microsoft 365 Apps</a></li>
      <li><a href="#authentication" id="markdown-toc-authentication">Authentication</a></li>
    </ul>
  </li>
  <li><a href="#install-view" id="markdown-toc-install-view">Install View</a></li>
  <li><a href="#update-view" id="markdown-toc-update-view">Update View</a></li>
  <li><a href="#settings" id="markdown-toc-settings">Settings</a></li>
  <li><a href="#log-panel" id="markdown-toc-log-panel">Log Panel</a></li>
  <li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
  <li><a href="#bonus-web-workbench" id="markdown-toc-bonus-web-workbench">Bonus: Web Workbench</a></li>
</ul>

<p>The Evergreen Workbench and the <a href="https://www.powershellgallery.com/packages/EvergreenUI/">EvergreenUI</a> PowerShell module - has reached its first stable release with version <strong>1.0.24</strong>. This module adds graphical front-end to the Evergreen module and provides many of the functions of Evergreen. It also includes a few workflows that integrate Evergreen, including importing Win32 packages into Microsoft Intune, importing Shell Apps into Nerdio Manager, and installing local applications.</p>

<p>If you’re not already familiar with <a href="https://eucpilots.com/evergreen">Evergreen</a>, it’s a PowerShell module that returns the latest version and download URI for 500+ applications. The Desktop Workbench wraps those same 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 more) behind an interactive Windows GUI, so you don’t need to fire up a terminal for day-to-day tasks.</p>

<p>In this article, I’ll walk through every view in the Desktop Workbench so you know what to expect when you install it.</p>

<p class="note">The Desktop Workbench is Windows-only - it’s built on WPF, so it requires Windows 10 / Windows Server 2019 or later, PowerShell 5.1 or 7.0+, and .NET Framework 4.7.2+ (or .NET 6+ for PowerShell 7). No additional DLLs are needed beyond what ships with Windows.</p>

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

<p>With the stable release, installation is straightforward from the PowerShell gallery:</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></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Evergreen</code> module is listed as a dependency, so PowerShell will pull it in automatically if you don’t already have it. Once installed, launch the Workbench:</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 depends on these additional modules: ‘Az.Accounts’, ‘Az.Resources’, ‘Az.Storage’, ‘IntuneWin32App’, ‘Microsoft.Graph.Authentication’. To install EvergreenUI and all depdendencies, use the following commands:</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="p">,</span><span class="w"> </span><span class="nx">Evergreen</span><span class="p">,</span><span class="w"> </span><span class="nx">Az.Accounts</span><span class="p">,</span><span class="w"> </span><span class="nx">Az.Resources</span><span class="p">,</span><span class="w"> </span><span class="nx">Az.Storage</span><span class="p">,</span><span class="w"> </span><span class="nx">IntuneWin32App</span><span class="p">,</span><span class="w"> </span><span class="nx">Microsoft.Graph.Authentication</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="nx">EvergreenUI</span><span class="w">
</span><span class="n">Start-EvergreenWorkbench</span><span class="w">
</span></code></pre></div></div>

<h2 id="apps-view">Apps View</h2>

<p>The Apps view shows all 500+ applications supported by Evergreen in a searchable list on the left, with version and download metadata on the right. Use the search box at the top of the list to filter by application name.</p>

<p><a href="/media/2026/06/evergreen-workbench-apps.png"><img src="/media/2026/06/evergreen-workbench-apps.png" alt="The Apps View showing Microsoft applications with version, channel, architecture, and type columns" /></a></p>

<p class="figcaption">Screenshot: Apps View with Microsoft applications.</p>

<p>Select an application from the list and click Refresh to see what <code class="language-plaintext highlighter-rouge">Get-EvergreenApp</code> returns for it - Version, and URI, along with additional data such as Date, Channel, Release, Architecture depending on the application. Metadata is cached so that a refresh does not need to be run each time.</p>

<h3 id="dynamic-filters">Dynamic Filters</h3>

<p>When you select an application, the filter options update automatically based on that application’s properties. The available filters vary by application and can include:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Property</th>
      <th style="text-align: left">Example values</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Architecture</td>
      <td style="text-align: left">x64, x86, ARM64</td>
    </tr>
    <tr>
      <td style="text-align: left">Channel</td>
      <td style="text-align: left">Stable, Beta, Dev</td>
    </tr>
    <tr>
      <td style="text-align: left">Ring</td>
      <td style="text-align: left">Production, Enterprise</td>
    </tr>
    <tr>
      <td style="text-align: left">Language</td>
      <td style="text-align: left">English, English (UK)</td>
    </tr>
    <tr>
      <td style="text-align: left">Type</td>
      <td style="text-align: left">exe, msi, msix, zip</td>
    </tr>
    <tr>
      <td style="text-align: left">Release</td>
      <td style="text-align: left">General, Enterprise</td>
    </tr>
  </tbody>
</table>

<p>Use <strong>Clear filters</strong> to reset, or <strong>Export to CSV</strong> to save the filtered results to disk.</p>

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

<p class="figcaption">Screenshot: Dynamic filters for Adobe Acrobat Reader DC.</p>

<h3 id="favourites-and-column-management">Favourites and Column Management</h3>

<p>You can star frequently used applications directly from the list - favourites are pinned to the top and saved in your local Workbench settings. The app detail header also shows a <strong>Last refresh</strong> timestamp when cached data is available.</p>

<p>Right-click any column header to show or hide optional columns. Structural columns like Version and URI stay visible; everything else is optional. Click a column header to sort ascending, then again to sort descending.</p>

<h2 id="download-view">Download View</h2>

<p>To download application installers, select one or more versions in the Apps View and click <strong>Add to download queue</strong>, then switch to the Download View to manage and kick off downloads.</p>

<p><a href="/media/2026/06/evergreen-workbench-download.png"><img src="/media/2026/06/evergreen-workbench-download.png" alt="The Download View showing a queued download for Adobe Acrobat Reader DC with status, version, architecture, and URI columns" /></a></p>

<p class="figcaption">Screenshot: Download View with a queued item.</p>

<p>Downloads are processed sequentially in queue order. The toolbar gives you the usual controls - remove selected, clear queue, open folder, and download all. A progress bar at the bottom tracks the active download. Once complete, the Path column fills in with the local file path.</p>

<h2 id="library-view">Library View</h2>

<p>If you’re already using an <a href="https://eucpilots.com/evergreen/updatelibrary">Evergreen library</a> (<code class="language-plaintext highlighter-rouge">New-EvergreenLibrary</code>, <code class="language-plaintext highlighter-rouge">Start-EvergreenLibraryUpdate</code>), the Library View puts a GUI on top of it. Point it at your library directory and it loads the contents - applications, version counts, and paths.</p>

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

<p class="figcaption">Screenshot: Library View showing installed versions and file details.</p>

<p>Select an application from the library list to see version details: version string, URI, type, size, SHA256 hash, release, and full file path for each version on disk. The toolbar covers the full library lifecycle - browse, open folder, create a new library, refresh the library contents, and trigger an update to pull the latest versions.</p>

<h2 id="import-tab">Import Tab</h2>

<p>The Import Tab is where the workbench includes workflows for Microsoft Intune or Nerdio Manager application management. It has four sub-tabs: <strong>Microsoft Intune Win32 Apps</strong>, <strong>Nerdio Manager Shell Apps</strong>, <strong>Microsoft 365 Apps</strong>, and <strong>Authentication</strong>.</p>

<p>Import-related modules are loaded when you first open the tab. Sign-in and import actions stay disabled until the required modules finish loading.</p>

<p>Each of the tabs relies on application package definitions that can be found here: <a href="https://github.com/EUCPilots/evergreen-packages">https://github.com/EUCPilots/evergreen-packages</a>. This repository includes as set of package definitions for importing supported apps into Intune and Nerdio Manager, including the Microsoft 365 Apps. The Intune and Nerdio Manager packages are currently seperate, but I would like to combine these into a single package definition in the future. The Microsoft 365 Apps configuration files support both Intune and Nerdio Manager.</p>

<h3 id="microsoft-intune-win32-apps">Microsoft Intune Win32 Apps</h3>

<p>Browse to a directory of Intune package definitions, load them, and the Workbench compares them against what’s currently deployed in your Intune tenant. The reconciliation grid shows each app with its Intune version, latest Evergreen version, status, and the action to take:</p>

<ul>
  <li><strong>Import new app</strong> - the definition isn’t in Intune yet</li>
  <li><strong>Import new version</strong> - an update is available</li>
  <li><strong>Fix in definition</strong> - there’s a definition issue (duplicate GUID, etc.)</li>
</ul>

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

<p class="figcaption">Screenshot: Intune Win32 Apps import tab with reconciliation view.</p>

<p>The <strong>Update definitions</strong> action resolves the latest Evergreen version for each definition and updates <code class="language-plaintext highlighter-rouge">App.json</code> and detection rules in place - useful for keeping your definition files current before importing.</p>

<h3 id="nerdio-manager-shell-apps">Nerdio Manager Shell Apps</h3>

<p>The Nerdio Manager sub-tab works the same way: point it at your Shell App definitions directory, compare against your Nerdio Manager environment, and import new apps or new versions as needed.</p>

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

<p class="figcaption">Screenshot: Nerdio Manager Shell Apps import tab.</p>

<h3 id="microsoft-365-apps">Microsoft 365 Apps</h3>

<p>Browse to a folder containing Office Deployment Tool XML configuration files, load them, and the grid shows each configuration with its display name, products, and validation status. From here you can package and import directly to either Nerdio Manager or Intune.</p>

<p><a href="/media/2026/06/evergreen-workbench-import-m365.png"><img src="/media/2026/06/evergreen-workbench-import-m365.png" alt="The Import Tab showing Microsoft 365 Apps configurations with package and import actions" /></a></p>

<p class="figcaption">Screenshot: Microsoft 365 Apps import tab.</p>

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

<p>The Authentication sub-tab manages connections to Entra ID (for Intune), the Nerdio Manager API, and optionally Azure Storage for storing Shell App packages. Connection indicators on the other import sub-tabs show the current authentication state.</p>

<p><a href="/media/2026/06/evergreen-workbench-import-auth.png"><img src="/media/2026/06/evergreen-workbench-import-auth.png" alt="The Authentication sub-tab showing Entra ID, Nerdio Manager API, and Azure Storage connection configuration" /></a></p>

<p class="figcaption">Screenshot: Authentication configuration sub-tab.</p>

<h2 id="install-view">Install View</h2>

<p>The Install tab is where you can run a local install of an application. Load the same directory of Intune package definitions, and it compares the defined versions against what’s currently installed on the machine. Each row shows the App, Publisher, Installed version, Latest version from Evergreen, Status, and the available Action.</p>

<p>This tab enables you to install apps locally for a quick install, test your application packages before uploading to Intune or Nerdio Manager, or it can provide a simple way to install set of a application into a gold image.</p>

<p><a href="/media/2026/06/evergreen-workbench-install.png"><img src="/media/2026/06/evergreen-workbench-install.png" alt="The Install View comparing installed application versions against the latest from Evergreen" /></a></p>

<p class="figcaption">Screenshot: Install View showing version comparisons and install status.</p>

<p>Status values are straightforward - Installed (up to date), Installed (update needed), or Not installed. Use <strong>Find latest versions</strong> to query Evergreen for each defined application, then <strong>Install selected</strong> to install or update the selected rows.</p>

<p class="note" title="Note">If the Workbench isn’t running elevated, installers are likely to launch a UAC prompt for elevation. The typical workflow for this tab should run from an elevated Terminal instance.</p>

<h2 id="update-view">Update View</h2>

<p>The Update View runs <code class="language-plaintext highlighter-rouge">Update-Evergreen</code> from the GUI - it synchronises the local application definitions cache from the <a href="https://github.com/EUCPilots/evergreen-apps">evergreen-apps</a> repository. The output panel shows timestamped log messages for the cache check, download, hash validation, and sync progress.</p>

<p><a href="/media/2026/06/evergreen-workbench-update.png"><img src="/media/2026/06/evergreen-workbench-update.png" alt="The Update View showing Update-Evergreen output with hash validation and sync progress" /></a></p>

<p class="figcaption">Screenshot: Update View running Update-Evergreen.</p>

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

<p>The Settings tab includes - download output path, the Evergreen apps cache path (read-only), light/dark theme, visibility toggles for the Import and Install tabs, and app version cache management, the Microsoft Intune package output path, and a Logs section for opening or clearing the local log directory.</p>

<p><a href="/media/2026/06/evergreen-workbench-settings.png"><img src="/media/2026/06/evergreen-workbench-settings.png" alt="The Settings View showing General, Microsoft Intune, and Logs configuration options" /></a></p>

<p class="figcaption">Screenshot: Settings View.</p>

<h2 id="log-panel">Log Panel</h2>

<p>Every operation - version lookups, downloads, library updates, imports - writes timestamped messages to the Log Panel at the bottom of the window at Info, Warning, or Error level. You can copy the log to clipboard, save it to a file, or toggle the panel visibility. Log messages come from background runspaces in real time, so you can watch progress as it happens.</p>

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

<p>With version <strong>1.0.24</strong>, the Evergreen Workbench moves from pre-release experiment to stable, general-availability release (to be fair, the workbench might forever be experimental). It covers the full workflow from browsing app versions and managing an Evergreen library, through to installing applications from package definitions and importing Win32 apps and Shell Apps directly into Microsoft Intune and Nerdio Manager. Install it from the PowerShell Gallery:</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="n">Start-EvergreenWorkbench</span><span class="w">
</span></code></pre></div></div>

<p>Full documentation is available at <a href="https://eucpilots.com/workbench-desktop">eucpilots.com</a>, and the source code and package definitions are on GitHub - star, fork, and contribute to the project:</p>

<ul>
  <li><a href="https://github.com/EUCPilots/evergreen-ui">EUCPilots/evergreen-ui</a> - Desktop Workbench source</li>
  <li><a href="https://github.com/EUCPilots/evergreen-packages">EUCPilots/evergreen-packages</a> - package definitions for Install and Import workflows</li>
</ul>

<h2 id="bonus-web-workbench">Bonus: Web Workbench</h2>

<p>The <a href="https://eucpilots.com/workbench">Web Workbench</a> is also available - it’s a browser-based view of all Evergreen-tracked applications, accessible on any platform. If you just need a quick way to look up an application’s latest version or download URL - on any platform, without installing anything - view the <a href="https://eucpilots.com/workbench">Web Workbench</a>. It also provides a dashboard, per-column filters, global search, CSV export, and per-app RSS feeds. It can be installed as a PWA for a standalone app experience.</p>

<p>The Web Workbench and the Desktop Workbench complement each other - the Web Workbench is great for quick lookups; the Desktop Workbench is where you go to actually act on that information.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Evergreen"/><summary type="html"><![CDATA[The Evergreen Workbench (EvergreenUI) has reached its first stable release. Here's a look at everything it can do - from browsing app versions and managing libraries, to importing packages directly into Microsoft Intune and Nerdio Manager.]]></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">Building Entra Dynamic Device Groups for AVD</title><link href="https://stealthpuppy.com/dynamic-groups-azure-virtual-desktop/" rel="alternate" title="Building Entra Dynamic Device Groups for AVD" type="text/html"/><published>2026-05-11T00:00:00+00:00</published><updated>2026-06-09T00:31:26+00:00</updated><id>https://stealthpuppy.com/entra-id-dynamic-groups-azure-virtual-desktop</id><content type="html" xml:base="https://stealthpuppy.com/dynamic-groups-azure-virtual-desktop/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#overview" id="markdown-toc-overview">Overview</a></li>
  <li><a href="#how-devicedevicephysicalids-works" id="markdown-toc-how-devicedevicephysicalids-works">How device.devicePhysicalIds works</a></li>
  <li><a href="#prerequisites" id="markdown-toc-prerequisites">Prerequisites</a></li>
  <li><a href="#rule-builder" id="markdown-toc-rule-builder">Rule builder</a></li>
  <li><a href="#creating-the-group-in-entra-id" id="markdown-toc-creating-the-group-in-entra-id">Creating the group in Entra ID</a></li>
  <li><a href="#assigning-policies-and-profiles" id="markdown-toc-assigning-policies-and-profiles">Assigning policies and profiles</a></li>
  <li><a href="#considerations" id="markdown-toc-considerations">Considerations</a></li>
</ul>

<h2 id="overview">Overview</h2>

<p>Managing Azure Virtual Desktop (AVD) session hosts at scale requires a reliable way to target them with policies, compliance rules, and configuration profiles in Microsoft Intune - without manually maintaining static group membership. Entra ID dynamic device groups solve this: membership is evaluated automatically based on device attributes, so session hosts are added or removed as they are provisioned or decommissioned.</p>

<p>For AVD, the most useful targeting attribute is <code class="language-plaintext highlighter-rouge">device.devicePhysicalIds</code>, which stores metadata that Azure writes onto each enrolled device during provisioning. One of those metadata values is the Azure Resource ID of the resource group the VM lives in. By building a membership rule that matches on that value, you get a group that precisely tracks every session host in a given host pool resource group - with no manual intervention required.</p>

<h2 id="how-devicedevicephysicalids-works">How device.devicePhysicalIds works</h2>

<p>When an Azure VM joins Entra ID (either natively or via hybrid join), the Azure platform writes a set of physical ID tags onto the device object. These tags are queryable in dynamic group membership rules using the <code class="language-plaintext highlighter-rouge">-any</code> operator.</p>

<p>The tag relevant to AVD is <code class="language-plaintext highlighter-rouge">[AzureResourceId]</code>, which takes the form:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[AzureResourceId]:/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}
</code></pre></div></div>

<p>This tag is written for every Azure VM that is enrolled into Entra ID, making it reliable for targeting AVD session hosts regardless of the VM SKU or image used.</p>

<p>The Entra ID dynamic membership rule syntax for matching this tag is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>device.devicePhysicalIds -any (_ -contains "[AzureResourceId]:/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}")
</code></pre></div></div>

<p>To target session hosts across multiple host pool resource groups within the same group, combine clauses with <code class="language-plaintext highlighter-rouge">or</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(device.devicePhysicalIds -any (_ -contains "[AzureResourceId]:/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup1}")) or (device.devicePhysicalIds -any (_ -contains "[AzureResourceId]:/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup2}"))
</code></pre></div></div>

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

<p>Before creating a dynamic group, confirm the following:</p>

<ul>
  <li><strong>Session hosts are Entra joined or hybrid joined.</strong> The <code class="language-plaintext highlighter-rouge">[AzureResourceId]</code> tag is only written for VMs enrolled in Entra ID. Workgroup VMs or VMs managed purely via Active Directory domain join without Entra hybrid join will not appear.</li>
  <li><strong>You have the Subscription ID and resource group names.</strong> Both are required for the rule. The subscription ID is a GUID visible in the Azure portal under <strong>Subscriptions</strong>, or via the Azure CLI with <code class="language-plaintext highlighter-rouge">az account show --query id</code>.</li>
  <li><strong>Intune enrollment is active.</strong> The device must be enrolled in Intune for the device object to exist and for policies to be applied to assignments using the group as a target.</li>
</ul>

<h2 id="rule-builder">Rule builder</h2>

<p>Use the tool below to generate the membership rule for your environment. Enter your Azure subscription ID and the name of each resource group that contains AVD session hosts. The rule updates as you type and can be copied directly into the Entra ID portal.</p>

<style>
/* AVD Dynamic Group Rule Builder — scoped styles */
.avd-dgb {
  font-family: var(--font-sans);
  border: 1px solid #e2e8f0;
  border-radius: 0.75rem;
  background: #f8fafc;
  padding: 1.5rem;
  margin: 1.5rem 0;
}
html.dark .avd-dgb {
  border-color: #334155;
  background: #1e293b;
}

.avd-dgb__header {
  margin-bottom: 1.25rem;
}
.avd-dgb__title {
  font-size: 1rem;
  font-weight: 600;
  color: #111827;
  margin: 0 0 0.25rem 0;
}
html.dark .avd-dgb__title {
  color: #f1f5f9;
}
.avd-dgb__subtitle {
  font-size: 0.875rem;
  color: #6b7280;
  margin: 0;
}
html.dark .avd-dgb__subtitle {
  color: #94a3b8;
}

.avd-dgb__section {
  margin-bottom: 1.25rem;
}
.avd-dgb__section:last-child {
  margin-bottom: 0;
}

.avd-dgb__label {
  display: block;
  font-size: 0.8125rem;
  font-weight: 500;
  color: #374151;
  margin-bottom: 0.375rem;
}
html.dark .avd-dgb__label {
  color: #cbd5e1;
}

.avd-dgb__label-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.5rem;
}
.avd-dgb__label-row .avd-dgb__label {
  margin-bottom: 0;
}

.avd-dgb__input {
  width: 100%;
  box-sizing: border-box;
  padding: 0.5rem 0.75rem;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  background: #ffffff;
  color: #111827;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
}
html.dark .avd-dgb__input {
  border-color: #334155;
  background: #0f172a;
  color: #e2e8f0;
}
.avd-dgb__input:focus {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 25%, transparent);
}
.avd-dgb__input--error {
  border-color: #f87171;
}
html.dark .avd-dgb__input--error {
  border-color: #f87171;
}
.avd-dgb__input::placeholder {
  color: #9ca3af;
  opacity: 1;
}
html.dark .avd-dgb__input::placeholder {
  color: #475569;
}

.avd-dgb__error {
  display: block;
  font-size: 0.75rem;
  color: #ef4444;
  margin-top: 0.25rem;
  min-height: 1rem;
}
html.dark .avd-dgb__error {
  color: #f87171;
}

.avd-dgb__rg-row {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-rows: auto auto;
  column-gap: 0.5rem;
  margin-bottom: 0.5rem;
  align-items: start;
}
.avd-dgb__rg-row .avd-dgb__input {
  grid-column: 1;
  grid-row: 1;
}
.avd-dgb__rg-row .avd-dgb__error {
  grid-column: 1;
  grid-row: 2;
}
.avd-dgb__remove-btn {
  grid-column: 2;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2.125rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  background: transparent;
  color: #9ca3af;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
  flex-shrink: 0;
  padding: 0;
}
html.dark .avd-dgb__remove-btn {
  border-color: #334155;
  color: #64748b;
}
.avd-dgb__remove-btn:hover:not(:disabled) {
  color: #ef4444;
  border-color: #f87171;
  background: color-mix(in srgb, #f87171 10%, transparent);
}
html.dark .avd-dgb__remove-btn:hover:not(:disabled) {
  color: #f87171;
  border-color: #f87171;
}
.avd-dgb__remove-btn:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}
.avd-dgb__remove-btn svg {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
}

/* Buttons */
.avd-dgb__btn {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.375rem 0.75rem;
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  font-weight: 500;
  border-radius: 0.375rem;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
  white-space: nowrap;
  line-height: 1.25;
}
.avd-dgb__btn svg {
  width: 0.875rem;
  height: 0.875rem;
  flex-shrink: 0;
}

/* Ghost — Add resource group */
.avd-dgb__btn--ghost {
  border: 1px solid var(--color-accent);
  color: var(--color-accent);
  background: transparent;
}
.avd-dgb__btn--ghost:hover {
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}

/* Accent — Copy */
.avd-dgb__btn--accent {
  border: 1px solid transparent;
  background: var(--color-accent-deepest);
  color: #ffffff;
}
.avd-dgb__btn--accent:hover:not(:disabled) {
  background: var(--color-accent-dark);
}
.avd-dgb__btn--accent:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* Output section */
.avd-dgb__output-wrap {
  border: 1px solid #e2e8f0;
  border-radius: 0.375rem;
  overflow: hidden;
}
html.dark .avd-dgb__output-wrap {
  border-color: #334155;
}

.avd-dgb__output {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin: 0;
  padding: 0.75rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-all;
  color: #374151;
  background: #ffffff;
  overflow-x: auto;
  min-height: 3.5rem;
}
html.dark .avd-dgb__output {
  color: #94a3b8;
  background: #0f172a;
}
.avd-dgb__output--ready {
  color: #111827;
}
html.dark .avd-dgb__output--ready {
  color: #e2e8f0;
}
</style>

<div id="avd-dgb" class="not-prose avd-dgb">

  <div class="avd-dgb__header">
    <p class="avd-dgb__title">Dynamic Group Rule Builder</p>
    <p class="avd-dgb__subtitle">Enter your Azure subscription ID and resource group names to generate the Entra ID dynamic device group membership rule.</p>
  </div>

  <!-- Subscription ID -->
  <div class="avd-dgb__section">
    <label for="avd-dgb-sub-id" class="avd-dgb__label">Azure Subscription ID</label>
    <input id="avd-dgb-sub-id" type="text" class="avd-dgb__input" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" aria-describedby="avd-dgb-sub-id-error" autocomplete="off" spellcheck="false" />
    <span id="avd-dgb-sub-id-error" class="avd-dgb__error" role="alert" aria-live="polite"></span>
  </div>

  <!-- Resource Group Names -->
  <div class="avd-dgb__section">
    <div class="avd-dgb__label-row">
      <span class="avd-dgb__label" id="avd-dgb-rg-label">Resource Group Names</span>
      <button type="button" id="avd-dgb-add-rg" class="avd-dgb__btn avd-dgb__btn--ghost" aria-label="Add another resource group">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
        Add
      </button>
    </div>
    <div id="avd-dgb-rg-list" aria-labelledby="avd-dgb-rg-label" role="list"></div>
  </div>

  <!-- Output -->
  <div class="avd-dgb__section">
    <div class="avd-dgb__label-row">
      <span class="avd-dgb__label">Generated Rule</span>
      <button type="button" id="avd-dgb-copy-btn" class="avd-dgb__btn avd-dgb__btn--accent" aria-label="Copy rule to clipboard" disabled="">
        <svg id="avd-dgb-copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
        <span id="avd-dgb-copy-text">Copy</span>
      </button>
    </div>
    <div class="avd-dgb__output-wrap" aria-live="polite" aria-atomic="true">
      <pre id="avd-dgb-output" class="avd-dgb__output">Enter a Subscription ID and at least one Resource Group name to generate the rule.</pre>
    </div>
  </div>

</div>

<script>
(function () {
  var PLACEHOLDER_TEXT = 'Enter a Subscription ID and at least one Resource Group name to generate the rule.';
  var NEEDS_RG_TEXT    = 'Add at least one Resource Group name to generate the rule.';

  var subInput  = document.getElementById('avd-dgb-sub-id');
  var subError  = document.getElementById('avd-dgb-sub-id-error');
  var rgList    = document.getElementById('avd-dgb-rg-list');
  var addBtn    = document.getElementById('avd-dgb-add-rg');
  var copyBtn   = document.getElementById('avd-dgb-copy-btn');
  var copyText  = document.getElementById('avd-dgb-copy-text');
  var copyIcon  = document.getElementById('avd-dgb-copy-icon');
  var outputPre = document.getElementById('avd-dgb-output');

  var COPY_ICON  = '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
  var CHECK_ICON = '<polyline points="20 6 9 17 4 12"/>';

  var rowCounter = 0;

  function isValidGuid(val) {
    return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val.trim());
  }

  function isValidRgName(val) {
    var s = val.trim();
    if (s.length < 1 || s.length > 90) return false;
    if (!/^[-\w\.\(\)]+$/.test(s)) return false;
    if (s.charAt(s.length - 1) === '.') return false;
    return true;
  }

  function generateRule(subId, rgNames) {
    return rgNames.map(function (rg) {
      return '(device.devicePhysicalIds -any (_ -contains "[AzureResourceId]:/subscriptions/' +
        subId.trim() + '/resourceGroups/' + rg.trim() + '"))';
    }).join(' or ');
  }

  function updateOutput() {
    var subVal   = subInput.value;
    var subValid = isValidGuid(subVal);

    // Subscription ID validation feedback
    if (subVal.trim() === '') {
      subInput.classList.remove('avd-dgb__input--error');
      subError.textContent = '';
    } else if (!subValid) {
      subInput.classList.add('avd-dgb__input--error');
      subError.textContent = 'Enter a valid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).';
    } else {
      subInput.classList.remove('avd-dgb__input--error');
      subError.textContent = '';
    }

    // Collect and validate RG rows
    var validRgs = [];
    var rgRows   = rgList.querySelectorAll('.avd-dgb__rg-row');
    rgRows.forEach(function (row) {
      var input = row.querySelector('input');
      var err   = row.querySelector('.avd-dgb__error');
      var val   = input.value;

      if (val.trim() === '') {
        input.classList.remove('avd-dgb__input--error');
        err.textContent = '';
      } else if (!isValidRgName(val)) {
        input.classList.add('avd-dgb__input--error');
        err.textContent = 'Invalid name: 1–90 chars, letters/digits/-_.(). Cannot end with a period.';
      } else {
        input.classList.remove('avd-dgb__input--error');
        err.textContent = '';
        validRgs.push(val.trim());
      }
    });

    // Generate output
    if (!subValid) {
      setOutput(PLACEHOLDER_TEXT, false);
    } else if (validRgs.length === 0) {
      setOutput(NEEDS_RG_TEXT, false);
    } else {
      setOutput(generateRule(subVal.trim(), validRgs), true);
    }
  }

  function setOutput(text, isRule) {
    outputPre.textContent = text;
    if (isRule) {
      outputPre.classList.add('avd-dgb__output--ready');
      copyBtn.disabled = false;
    } else {
      outputPre.classList.remove('avd-dgb__output--ready');
      copyBtn.disabled = true;
    }
  }

  function updateRemoveButtons() {
    var rows = rgList.querySelectorAll('.avd-dgb__rg-row');
    rows.forEach(function (row) {
      var btn = row.querySelector('.avd-dgb__remove-btn');
      btn.disabled = (rows.length === 1);
    });
  }

  function addRgRow(focusInput) {
    var id = rowCounter++;

    var row = document.createElement('div');
    row.className = 'avd-dgb__rg-row';
    row.setAttribute('role', 'listitem');

    var input = document.createElement('input');
    input.type = 'text';
    input.className = 'avd-dgb__input';
    input.placeholder = 'rg-avd-sessionhosts';
    input.id = 'avd-dgb-rg-' + id;
    input.setAttribute('aria-label', 'Resource group name');
    input.setAttribute('aria-describedby', 'avd-dgb-rg-err-' + id);
    input.setAttribute('autocomplete', 'off');
    input.setAttribute('spellcheck', 'false');
    input.addEventListener('input', updateOutput);

    var removeBtn = document.createElement('button');
    removeBtn.type = 'button';
    removeBtn.className = 'avd-dgb__remove-btn';
    removeBtn.setAttribute('aria-label', 'Remove this resource group');
    removeBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
    removeBtn.addEventListener('click', function () {
      row.remove();
      updateOutput();
      updateRemoveButtons();
    });

    var errorSpan = document.createElement('span');
    errorSpan.id = 'avd-dgb-rg-err-' + id;
    errorSpan.className = 'avd-dgb__error';
    errorSpan.setAttribute('role', 'alert');
    errorSpan.setAttribute('aria-live', 'polite');

    row.appendChild(input);
    row.appendChild(removeBtn);
    row.appendChild(errorSpan);
    rgList.appendChild(row);

    updateRemoveButtons();

    if (focusInput !== false) {
      input.focus();
    }
  }

  // Copy to clipboard
  copyBtn.addEventListener('click', function () {
    var text = outputPre.textContent;
    navigator.clipboard.writeText(text).then(function () {
      copyIcon.innerHTML = CHECK_ICON;
      copyText.textContent = 'Copied!';
      copyBtn.setAttribute('aria-label', 'Copied to clipboard');
      setTimeout(function () {
        copyIcon.innerHTML = COPY_ICON;
        copyText.textContent = 'Copy';
        copyBtn.setAttribute('aria-label', 'Copy rule to clipboard');
      }, 2000);
    });
  });

  addBtn.addEventListener('click', function () {
    addRgRow(true);
    updateOutput();
  });

  subInput.addEventListener('input', updateOutput);

  // Initialise with one empty RG row
  addRgRow(false);
  updateOutput();
}());
</script>

<h2 id="creating-the-group-in-entra-id">Creating the group in Entra ID</h2>

<p>Once you have the generated rule, create the dynamic device group:</p>

<ol>
  <li>Open the <a href="https://entra.microsoft.com">Microsoft Entra admin center</a> and navigate to <strong>Groups</strong> → <strong>All groups</strong> → <strong>New group</strong>.</li>
  <li>Set <strong>Group type</strong> to <strong>Security</strong> and <strong>Membership type</strong> to <strong>Dynamic Device</strong>.</li>
  <li>Enter a <strong>Group name</strong> - for example, <code class="language-plaintext highlighter-rouge">devicegroup-avd-sessionhosts-prod</code>.</li>
  <li>Click <strong>Add dynamic query</strong>.</li>
  <li>Switch to the <strong>Advanced rule</strong> editor and paste the generated rule.</li>
  <li>Click <strong>Validate Rules</strong> to test against existing devices before saving.</li>
  <li>Click <strong>Save</strong>, then <strong>Create</strong>.</li>
</ol>

<p>Membership is evaluated by Entra ID within a few minutes of creation and then kept up to date continuously. New session hosts provisioned into the matched resource groups will be added automatically once they enrol in Intune.</p>

<h2 id="assigning-policies-and-profiles">Assigning policies and profiles</h2>

<p>With the dynamic group in place, assign Intune configuration profiles and compliance policies to it as you would any other group:</p>

<ul>
  <li><strong>Device configuration profiles</strong> - apply Windows settings, certificate delivery, endpoint protection policies, etc., scoped to AVD session hosts.</li>
  <li><strong>Compliance policies</strong> - evaluate session hosts against your organisational standards; non-compliant devices can be flagged or blocked via Conditional Access.</li>
  <li><strong>App assignments</strong> - push required or available apps to session hosts. For multi-session hosts shared across users, use <strong>device-targeted</strong> assignments rather than user-targeted ones.</li>
  <li><strong>Windows Update for Business rings</strong> - control servicing cadence per host pool by scoping update rings to individual dynamic groups.</li>
</ul>

<h2 id="considerations">Considerations</h2>

<p><strong>AVD Hybrid.</strong> This has not yet been tested against virtual machines deployed into an AVD Hybrid environment; however, given that these VMs are registered into a resource group via Azure Arc, this approach should work, in theory.</p>

<p><strong>Subscription scope.</strong> Each rule targets a single subscription. If your AVD environment spans multiple subscriptions, create one dynamic group per subscription (or per set of resource groups within a subscription) and nest them under a parent security group where Intune supports group nesting for assignment.</p>

<p><strong>Rule complexity limits.</strong> Entra ID enforces a maximum rule length of 3,072 characters. A rule covering many resource groups will grow quickly; split into multiple groups if you approach the limit.</p>

<p><strong>Sync latency.</strong> Dynamic membership evaluation is near-real-time for most changes but can take up to 24 hours in large tenants. Newly provisioned session hosts may not appear in the group immediately - factor this into provisioning workflows that depend on policy application at first login.</p>

<p><strong>Hybrid join vs. Entra join.</strong> Both join types result in the <code class="language-plaintext highlighter-rouge">[AzureResourceId]</code> tag being written, so the rule works for both. However, for hybrid-joined devices there is an additional propagation step through Entra Connect Sync, which adds latency.</p>

<p><strong>Personal vs. pooled host pools.</strong> The resource group–based rule does not distinguish between personal and pooled configurations - it matches all VMs in the resource group. If you need to separate policy targeting between personal and pooled hosts, place them in separate resource groups and use separate dynamic groups.</p>]]></content><author><name>Aaron Parker</name><email>aaron@stealthpuppy.com</email></author><category term="Microsoft"/><category term="Azure Virtual Desktop"/><category term="Entra ID"/><category term="Intune"/><summary type="html"><![CDATA[Use Entra ID dynamic device groups to automatically target Azure Virtual Desktop session hosts by subscription and resource group - with an interactive rule builder.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stealthpuppy.com/assets/img/group/image.jpg"/><media:content xmlns:media="http://search.yahoo.com/mrss/" medium="image" url="https://stealthpuppy.com/assets/img/group/image.jpg"/></entry><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-06-09T00:31:26+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/workbench">https://eucpilots.com/evergreen/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/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/workbench">https://eucpilots.com/evergreen/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-06-09T00:31:26+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-06-09T00:31:26+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-06-09T00:31:26+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-06-09T00:31:26+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-06-09T00:31:26+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-06-09T00:31:26+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-06-09T00:31:26+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></feed>