Skip to content

User guide

This guide covers how each command works, authentication, state, and the configuration layers.

How NAPT works

Discovery process (napt discover)

The discovery process finds the latest version and downloads the installer:

  1. Load Configuration - Merges organization defaults, vendor defaults, and recipe configuration
  2. Check Version - Uses the configured discovery strategy to check for new versions
  3. Skip or Download:
    • If the strategy reports the same version as last run (or, for url_download, the server answers 304 Not Modified) → Skip download
    • Otherwise → Download installer
  4. Read the Version - Takes the version from the installer itself (MSI ProductVersion, MSIX Identity), falling back to the version the strategy reported for installers that carry none (EXE). This is the version recorded and used everywhere after
  5. Record Pending Release - Updates state/deployment/{app_id}.json with the discovered release as the pending publication candidate when it differs from the published version. The pending slot holds one candidate and the newest discovery wins.

Output: Downloaded installer in downloads/{app_id}/{version}/, updated deployment state

One folder per version: Each download is filed under its version, the same way builds and packages are. A vendor that serves every release under one filename (Chrome's googlechromestandaloneenterprise64.msi, for example) therefore cannot overwrite an installer that is still waiting for approval.

Saved filename: The file is saved under the name the server announces, or the URL's filename when it announces none. NAPT keeps only the final part of that name and replaces $, ;, backticks, and quote characters with _, because {{installer_filename}} is substituted into your install script. A warning shows the original and saved names when they differ. Always put {{installer_filename}} inside quotes: unquoted, PowerShell runs parentheses in a filename as code, and those are too common in real names to replace.

Version: The version becomes a folder name (downloads/{app_id}/{version}/), so it may contain only letters, digits, ., -, _, and +. It must also start with a digit: a device compares versions by each part's leading digits, so v2.0 would read as version 0 there, every device would report it installed, and none would upgrade to it. If discovery stops with "cannot be used as a folder name" or "does not start with a number", tighten the recipe's version_pattern so it captures only the version. An MSI or MSIX is judged by its own version, so this only comes up for EXE recipes whose pattern (or api_json value) keeps a prefix.

Build process (napt build)

The build process creates a complete PSADT package from the recipe and downloaded installer:

  1. Load Configuration - Merges configuration layers (org → vendor → recipe)
  2. Find Installer - Reads the release to build from state/deployment/{app_id}.json (the pending release, or the published one when nothing is pending), looks in downloads/{app_id}/{version}/, and takes the file whose SHA-256 matches the recorded hash. A file that changed since discovery is refused. With no recorded release (a --stateless discover and no state), the single installer found in a version folder (downloads/{app_id}/{version}/) is used; more than one stops the build, and a file placed directly in downloads/{app_id}/ is not found
  3. Confirm Version - The version is the name of the download folder. For an MSI or MSIX, build reads the installer's own version and refuses to continue if it differs from the folder, since that means a file was moved by hand
  4. Get PSADT Release - Downloads/caches PSADT Template_v4 from GitHub if not already cached
  5. Create Build Directory - Creates versioned directory using discovered app version: builds/{app_id}/{version}/
  6. Copy PSADT Template - Copies entire PSADT template structure (unmodified) from cache:
    • PSAppDeployToolkit/ - Core PSADT module
    • PSAppDeployToolkit.Extensions/ - Extension modules
    • Assets/ - Default icons and banners
    • Config/ - Default configuration files
    • Strings/ - Localization strings
    • Files/ - Empty directory for installer files
    • SupportFiles/ - Empty directory for additional files
    • Invoke-AppDeployToolkit.exe - Compiled launcher
    • Invoke-AppDeployToolkit.ps1 - Template script (will be overwritten)
  7. Generate Deployment Script - Generates Invoke-AppDeployToolkit.ps1 from template:
    • Substitutes PSADT variables ($appVendor, $appName, $appVersion, etc.) from recipe configuration
    • Inserts install script from psadt.install field (for MSI, auto-generates install/uninstall commands from the MSI metadata, the exact filename and ProductName, unless override_msi_commands: true; for MSIX, auto-generates them from the manifest based on intune.run_as_account unless override_msix_commands: true)
    • Inserts uninstall script from psadt.uninstall field
    • Sets dynamic values (AppScriptDate, discovered version, PSADT version)
    • Preserves PSADT's structure and comments
  8. Copy Installer - Copies downloaded installer file to Files/ directory:
    • Source: downloads/{app_id}/{version}/{installer_filename}
    • Destination: builds/{app_id}/{version}/Files/{installer_filename}
    • Installer is accessible in scripts via $($adtSession.DirFiles) (PSADT 4.x)
  9. Apply Branding - Replaces PSADT default assets with custom branding (if configured):
    • Reads brand_pack configuration from org/vendor defaults
    • Replaces files in Assets/ directory (AppIcon.png, Banner.Classic.png, etc.)
    • Uses pattern matching to find source files in brand pack directory
  10. Generate Detection and Requirements Scripts - Creates PowerShell scripts for Intune Win32 app deployment (detection always generated; requirements only when build_types is both or update_only). See Detection and Requirements Scripts below for details.

Output: Complete PSADT package in builds/{app_id}/{version}/ with detection script always present, and requirements script when build_types is both or update_only.

Detection and requirements scripts

NAPT generates PowerShell scripts used by Intune Win32 app entries to check installation state:

  • Detection script (always generated): Used by the App entry and by the Update entry when using the two-app model to determine if the app is installed at the expected version. Filename: {AppName}_{Version}-Detection.ps1.
  • Requirements script (when build_types is both or update_only): Used by the Update entry to determine if an older version is installed so Intune can offer the update. Filename: {AppName}_{Version}-Requirements.ps1.

For MSI and EXE installers, both scripts share the same logic for registry lookup, app name resolution, and installer-type filtering; they differ only in how they interpret the version comparison (see below). For MSIX installers, scripts query the AppX package database by identity name instead of registry scanning; which store is queried depends on intune.run_as_account (see below).

How the scripts work:

  • Registry locations checked (architecture-aware):

    • Scripts use explicit RegistryView (Registry64 or Registry32) for deterministic behavior regardless of PowerShell process bitness
    • For x64/arm64 architecture (or 64-bit view when architecture is "any"):
      • HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall (machine-level)
      • HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall (user-level)
    • For x86 architecture (or 32-bit view when architecture is "any" on 64-bit OS):
      • HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall (machine-level)
      • HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall (user-level)
    • For x86 architecture on 32-bit OS:
      • HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall (machine-level)
      • HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall (user-level)
    • When architecture is "any" (default): Checks both 64-bit and 32-bit views (all applicable paths above)
  • App name determination:

    • MSI installers: Uses MSI ProductName property (authoritative source for registry DisplayName). For MSIs where the vendor includes version in the ProductName (e.g., "7-Zip 25.01"), use intune.detection.override_msi_display_name: true to specify a custom display_name pattern instead. See Recipe Reference - detection for details.
    • Non-MSI installers: Requires intune.detection.display_name in recipe configuration. Scripts match registry DisplayName to this value.
  • Installer type filtering:

    • MSI installers (strict): Only match registry entries that are MSI-based (checks WindowsInstaller = 1). Prevents false matches when both MSI and EXE versions exist.
    • Non-MSI installers (permissive): Match any registry entry (MSI or non-MSI) to handle EXE installers that run embedded MSIs internally.
  • Architecture filtering:

    • Controls which registry views are checked based on the installer architecture NAPT resolves at build time (NAPT sets AppArch in the generated script; it is not a recipe app_vars key)
    • MSI installers: architecture is extracted from MSI package metadata (no manual configuration needed)
    • Non-MSI installers: architecture must be specified in intune.detection (e.g., architecture: "x64")
    • Architecture values:
      • x64 / arm64: Checks only 64-bit registry view (ARM64 uses 64-bit registry)
      • x86: Checks only 32-bit registry view
      • any (default if not specified): Checks both 64-bit and 32-bit views for maximum compatibility
    • Prevents false matches when both 32-bit and 64-bit versions of the same software are installed
  • MSIX detection (AppX package-based):

    • MSIX installers query the Windows AppX package database by package identity name (from AppxManifest.xml), not the registry
    • Which store is queried depends on intune.run_as_account:
      • "system" (default): Get-AppxProvisionedPackage -Online (provisioned/all-users store)
      • "user": Get-AppxPackage -Name (per-user store)
    • Architecture is auto-detected from the MSIX manifest's ProcessorArchitecture attribute
    • The intune.detection.display_name, architecture, and override_msi_display_name fields are not used for MSIX installers
  • Logging:

    • Format: CMTrace format for compatibility with Intune diagnostics tools
    • Primary location (both contexts): C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\
      • Detection: NAPTDetections.log (system) / NAPTDetectionsUser.log (user)
      • Requirements: NAPTRequirements.log (system) / NAPTRequirementsUser.log (user)
    • Fallback locations (used if primary location fails):
      • System context: C:\ProgramData\NAPT\
      • User context: %LOCALAPPDATA%\NAPT\
      • Same log file names as primary locations
    • Fallback behavior: Script tries primary first (creates directory if needed, verifies write access). If that fails (insufficient permissions), tries fallback. If both fail, script continues with a warning to stderr but no log file
    • Log rotation: 2-file rotation (.log and .log.old), default 3MB max size per file

Detection vs Requirements scripts:

  • Detection script - Checks if the application is installed at the expected version:
    • Version check: Compares installed version to expected version
    • Match modes: Exact match (installed = expected) or minimum version (installed >= expected)
    • Exit codes: Exit 0 if installed and meets requirement, exit 1 otherwise
  • Requirements script - Determines if an installed application needs to be updated:
    • Version check: Determines if installed version < target version
    • Output: Writes "Required" to stdout if update needed, nothing otherwise
    • Exit codes: Always exits 0 (allows Intune to evaluate stdout)
    • Intune configuration: Requirement rule with output type String, operator Equals, value "Required"

Output location and packaging:

Scripts are saved as siblings to the packagefiles/ directory and are NOT included in the .intunewin package:

builds/napt-chrome/144.0.7559.110/
  ├── packagefiles/                                 # PSADT package (packaged into .intunewin)
  │   └── ...
  ├── Google-Chrome_144.0.7559.110-Detection.ps1    # Detection script
  ├── Google-Chrome_144.0.7559.110-Requirements.ps1 # Requirements script (if generated)
  └── build-manifest.json                           # Installer hash and metadata

Configuration: See Recipe Reference - Intune Configuration for intune.detection and intune.build_types options.

App icons

During napt build, NAPT extracts the app's icon from the installer and saves it to {directories.icons}/{id}.png (default icons/). napt upload sends that file as the app's logo in Intune and the Company Portal.

Extraction sources per installer type:

  • MSI - The Icon table (preferring the row named by ARPPRODUCTICON). If the MSI has no usable Icon table entry, NAPT performs an administrative extract (msiexec /a) and scans the contained executables for icons
  • EXE - The executable's own icon resources
  • MSIX - Logo assets declared in AppxManifest.xml, including scale and targetsize variants

Extraction rules:

  • Only icon frames that are already PNG-encoded are used; NAPT does not re-encode or upscale images
  • Frames must be at least 128px wide and at most 700KB (Intune rejects icons over 750KB)
  • Among qualifying frames, the one closest to Intune's recommended 256px is selected
  • If no qualifying frame exists, the build prints a warning and continues without an icon. The failure is recorded in an icons/{id}.no-icon marker so expensive MSI extraction is not repeated on every build; the marker invalidates itself when the installer changes

Like downloads/ and builds/, the icons directory is a machine-local output directory (gitignored); each machine extracts its own icons at build time, and NAPT never overwrites an existing icon file.

Icon resolution order at upload: intune.logo_path (if set), then icons/{id}.png, then no icon with a warning. To replace or pin an icon, see Set a custom app icon.

Package process (napt package)

The package process creates a .intunewin file from a PSADT build for the recipe's app:

  1. Resolve Build Directory - Scans builds/{app_id}/ for the most recently modified version directory that contains a packagefiles/ folder. Use --version VERSION to target a specific version instead
  2. Verify Structure - Validates the build directory has the required PSADT structure:
    • PSAppDeployToolkit/ directory
    • Files/ directory
    • Invoke-AppDeployToolkit.ps1 script
    • Invoke-AppDeployToolkit.exe launcher
  3. Get IntuneWinAppUtil - Downloads/caches IntuneWinAppUtil.exe from Microsoft's GitHub repository. The release is controlled by intunewin.release in defaults/org.yaml (default: "latest"). The tool is cached under cache/tools/{version}/ so each pinned release is stored independently
  4. Create Package - Runs IntuneWinAppUtil.exe to create .intunewin file:
    • Input: packagefiles/ subdirectory of the build (PSADT structure)
    • Output: Invoke-AppDeployToolkit.intunewin in packages/{app_id}/{version}/
    • Previous version directory for this app is removed automatically
  5. Copy Detection Scripts - Copies *-Detection.ps1, *-Requirements.ps1, and build-manifest.json from the build version directory into packages/{app_id}/{version}/ so that napt upload is self-contained and does not need access to the builds directory
  6. Optional Cleanup - If --clean-source flag is used, removes the build version directory after successful packaging

Output: .intunewin and detection scripts in packages/{app_id}/{version}/, ready for napt upload. Only one version is kept on disk per app at a time: packaging a new version removes the previous one automatically.

Upload process (napt upload)

The upload process publishes a packaged app to Microsoft Intune via the Graph API. Run napt package before uploading.

  1. Locate Package - Scans packages/{app_id}/ (directories.package) for the versioned subdirectory created by napt package and reads Invoke-AppDeployToolkit.intunewin from it. Verifies the package's installer hash (from the build manifest) against the pending release in the app's deployment state; a mismatch aborts the upload, so what was recorded at discovery is byte-for-byte what ships. When no pending release is recorded, the upload proceeds with a warning, or fails when deployment.require_pending is enabled
  2. Authenticate - Uses the CI/CD environment credential or the session from napt auth login (see Authentication below)
  3. Parse Package Metadata - Reads encryption metadata from Detection.xml inside the .intunewin ZIP 4–6. Create, Upload, Commit (install entry) - Creates the Win32 app record using the base app name and detection script only, uploads the encrypted payload to Azure Blob Storage, and commits the content version. Skipped when build_types is "update_only" 7–9. Create, Upload, Commit (update entry) - Creates a second Win32 app record using update_name_prefix + name and detection + requirements scripts, uploads the same encrypted payload, and commits. Skipped when build_types is "app_only". When build_types is "both" (default), this runs after the install entry is fully committed

Each created app entry carries a provenance stamp in its Intune notes field: napt/v1 id=<recipe-id> entry=<install|update> sha256=<installer-hash>. The stamp marks the app as NAPT-managed and ties it to the exact binary it was built from; the notes field is reserved for NAPT and is not recipe-configurable. On success, the app's deployment state records the published version, hash, and both Intune app IDs, and a matching pending slot is cleared.

Re-running an upload is safe. Before creating anything, NAPT lists the tenant's apps and looks for stamps matching this release: a fully published match is adopted as-is, a match whose content was never committed (a crashed previous run) gets a fresh content upload, and only missing entries are created. Apps without a NAPT stamp are never touched.

Adoption keeps the matched app exactly as it is: it does not re-send metadata or package content, since the match key is the installer binary. If you changed the recipe or package without a new installer release (PSADT commands, detection settings, icon), pass --force to update the matched apps' metadata and upload a fresh content version. --force never creates duplicates.

Output: Intune Win32 App ID (install entry), Intune Win32 Update ID (update entry), app name, version, and package path. Each ID is omitted when its corresponding entry is not created

Authentication

napt upload, napt promote apply, and napt promote plan --reconcile/--check-drift all need a Microsoft Graph token. NAPT resolves it the same way every time; napt auth status shows which source it picked:

Method When it's used
Service principal (EnvironmentCredential) AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_CLIENT_SECRET (or AZURE_CLIENT_CERTIFICATE_PATH) are set (CI/CD)
Interactive session (napt auth login) Nothing above is set and you have signed in on this machine (developers)
Azure CLI (AzureCliCredential) Nothing above applies and the Azure CLI is signed in as a service principal (CI/CD with OIDC through a login step such as GitHub Actions azure/login). Recommended over a client secret whenever your CI platform supports it. A CLI signed in as a person is refused, since its tokens belong to the Azure CLI's own application, not the NAPT registration

NAPT never opens a browser on its own. If no credential is available, commands fail with Not authenticated. followed by a hint for each option: run napt auth login interactively, or set the AZURE_* variables or sign in with az login for CI/CD.

App registration setup

Create the app registration once per organization. Two ways to do it:

Manual (Entra portal):

  1. Go to entra.microsoft.comApp registrationsNew registration
  2. Name it (e.g. "NAPT"), leave redirect URI blank, click Register
  3. Note the Application (client) ID and Directory (tenant) ID
  4. Go to API permissionsAdd a permissionMicrosoft GraphApplication permissions → add DeviceManagementApps.ReadWrite.All and Group.Read.All (used by CI/CD; Group.Read.All resolves Entra ID group names in deployment: configuration to object IDs)
  5. Repeat for Delegated permissions → add the same two (used by interactive sign-in)
  6. Click Grant admin consent
  7. Go to AuthenticationAdd a platformMobile and desktop applications and add these redirect URIs:
    • http://localhost (browser sign-in)
    • ms-appx-web://Microsoft.AAD.BrokerPlugin/<Application (client) ID> (Windows broker sign-in)

Automatic (napt auth setup):

napt auth setup --tenant-id "<Directory (tenant) ID>"

Signs you in through the browser as an account holding the Application Administrator role (or higher) and does everything in the manual list through Microsoft Graph: creates the registration (or finds one named NAPT; use --name or --client-id to target another), adds the redirect URIs and the application + delegated permissions, creates the service principal, and grants admin consent. It then remembers the tenant and client ID so the next step is just napt auth login.

The registration is stamped in its Internal notes (Branding & properties) with a provenance line such as napt/v1 spec=1 version=0.10.0 provisioned=2026-08-18; any notes an administrator adds below it are preserved. Re-running is safe: NAPT compares the registration against what the installed version needs and adds only what is missing, so when a NAPT release needs a new permission, updating is just running napt auth setup again. A registration that matches by name but carries no stamp (one made in the portal, for example) is not touched until you pass --adopt, which adds NAPT's redirect URIs, Graph permissions, and admin consent to it and stamps it; nothing existing is ever removed. Naming the registration explicitly with --client-id counts as that consent.

Add --federated-issuer and --federated-subject to also create the federated credential for OIDC CI/CD (below) in the same run; the values come from your CI platform's OIDC documentation. The administrator sign-in uses the Microsoft Graph Command Line Tools app (the same one Connect-MgGraph uses); NAPT does not store that account or its tokens, though your browser may keep its own sign-in. If your tenant blocks that app, --print-only prints the portal checklist with the exact values for someone to click through instead.

Developer setup:

Sign in once; the client and tenant IDs are remembered for later logins:

napt auth login --tenant-id "<Directory (tenant) ID>" --client-id "<Application (client) ID>"

On Windows this opens the OS account picker (Web Account Manager), signing you in with your work account, honoring device-based Conditional Access, and keeping the refresh token device-bound. Elsewhere, or with --no-broker, it opens your browser. The broker needs an interactive Windows session: from a scheduled task, service, runas, or SSH session, use a service principal or OIDC instead. Tokens are cached in the OS credential store (DPAPI, Keychain, or libsecret) and refreshed silently until the session expires or is revoked. The remembered tenants and the cache live under %LOCALAPPDATA%\napt (Windows), ~/Library/Application Support/napt (macOS), or $XDG_CONFIG_HOME/napt falling back to ~/.config/napt (Linux); set NAPT_USER_DIR to relocate them. The AZURE_* environment variables play no part in interactive sign-in; they are how CI/CD supplies a credential (below), and when they are set they take precedence over your session.

Check what you are holding at any time:

$ napt auth status
Method:      interactive (broker)
Account:     admin@contoso.com
Tenant:      00000000-0000-0000-0000-000000000000
Client ID:   11111111-1111-1111-1111-111111111111
Expires:     2026-08-16T19:04:11+00:00
Permissions: DeviceManagementApps.ReadWrite.All, Group.Read.All

Known tenants:
  * Contoso (contoso.com)
      Account:   admin@contoso.com
      Tenant ID: 00000000-0000-0000-0000-000000000000
      Client ID: 11111111-1111-1111-1111-111111111111
    Contoso Dev (contosodev.onmicrosoft.com)
      Account:   (signed out)
      Tenant ID: 22222222-2222-2222-2222-222222222222
      Client ID: 33333333-3333-3333-3333-333333333333
  (* = active; switch with 'napt auth login --tenant-id <id or domain>')

The tenant's default domain and display name are looked up once at login through the delegated User.Read permission, which new app registrations carry by default. Without it the tenant is listed as (name unknown); sign-in itself is unaffected. napt auth status exits 1 when no credential is available or a required permission is missing, and names the missing permission. napt auth logout signs out of the active tenant (--all for every tenant); the IDs stay remembered.

Multiple tenants:

Sign in to each tenant once with its own client ID. NAPT remembers every tenant you have signed in to and which one is active; napt auth status lists them. Switch with just the tenant ID (or its default domain, once known) with no prompt as long as that tenant's session is still valid:

napt auth login --tenant-id "<prod tenant ID>" --client-id "<prod client ID>"   # first time
napt auth login --tenant-id contosodev.onmicrosoft.com                           # switch back, silent

CI/CD setup, OIDC federation (recommended):

No secret to store or rotate. Create a federated credential on the app registration that trusts your CI platform's OIDC issuer for the workflow that runs NAPT. Scope it to a deployment environment rather than a branch, so only the jobs that declare that environment (the ones that touch Intune) can obtain tokens; merges to main stay gated by your branch protection and PR review as usual. For GitHub Actions and an environment named intune:

napt auth setup --tenant-id "<Directory (tenant) ID>" \
  --federated-issuer https://token.actions.githubusercontent.com \
  --federated-subject "repo:owner@<owner id>/name@<repository id>:environment:intune"

Use the subject your repository actually emits. GitHub's immutable format above embeds the owner and repository IDs so a renamed or recreated repository cannot inherit the trust; repositories created or renamed after 2026-07-15 emit it by default, older ones until opted in emit repo:owner/name:environment:intune. See Migrate GitHub Actions federated credentials to immutable subjects and GitHub's OIDC reference for the IDs and the opt-in. Other platforms use their own subject format (see their OIDC documentation); by hand, the same values go under Certificates & secretsFederated credentialsAdd credentialOther issuer. Then sign in with azure/login before NAPT runs:

permissions:
  id-token: write
  contents: read

jobs:
  upload:
    runs-on: ubuntu-latest
    environment: intune
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          allow-no-subscriptions: true
      - run: napt upload recipes/Google/chrome.yaml

azure/login exchanges the workflow's OIDC token for an Azure CLI session; NAPT then obtains its Graph token from that session (AzureCliCredential), so the job needs no AZURE_* variables at all.

CI/CD setup, client secret:

Create a client secret under Certificates & secretsNew client secret. Set all three environment variables as pipeline secrets:

AZURE_CLIENT_ID="<Application (client) ID>"
AZURE_CLIENT_SECRET="<client secret value>"
AZURE_TENANT_ID="<Directory (tenant) ID>"

A certificate works the same way with AZURE_CLIENT_CERTIFICATE_PATH instead of AZURE_CLIENT_SECRET.

Directory structure

After a complete workflow, your directory structure looks like:

downloads/
  └── napt-chrome/
      ├── .download.json                   # What the last discover run resolved
      └── 142.0.7444.163/
          └── googlechromestandaloneenterprise64.msi

builds/
  └── napt-chrome/
      └── 142.0.7444.163/
          ├── packagefiles/                # PSADT package contents
          │   ├── PSAppDeployToolkit/      # PSADT module (from template)
          │   ├── PSAppDeployToolkit.Extensions/
          │   ├── Assets/                  # Custom branding (if configured)
          │   ├── Config/
          │   ├── Strings/
          │   ├── Files/                   # Installer copied here
          │   │   └── googlechromestandaloneenterprise64.msi
          │   ├── SupportFiles/            # Empty (for additional files)
          │   ├── Invoke-AppDeployToolkit.ps1  # Generated script
          │   └── Invoke-AppDeployToolkit.exe  # From template
          ├── Google-Chrome_142.0.7444.163-Detection.ps1
          ├── Google-Chrome_142.0.7444.163-Requirements.ps1
          └── build-manifest.json              # Installer hash and metadata

packages/
  └── napt-chrome/
      └── 142.0.7444.163/                              # One version kept at a time
          ├── Invoke-AppDeployToolkit.intunewin        # Encrypted package
          ├── Google-Chrome_142.0.7444.163-Detection.ps1    # Copied by napt package
          ├── Google-Chrome_142.0.7444.163-Requirements.ps1 # Copied by napt package
          └── build-manifest.json                      # Copied by napt package; read by napt upload

state/
  └── deployment/
      └── napt-chrome.json                 # Deployment state (authoritative)

Commands reference

Tip: All commands support --help (or -h) for detailed usage, options, and examples.

napt init

Initializes a new NAPT project with the recommended directory structure. Creates recipes/, defaults/org.yaml, defaults/vendors/, and state/deployment/. Existing files are preserved by default; use --force to backup and overwrite.

napt init [DIRECTORY] [OPTIONS]

napt validate

Validates recipe syntax and configuration without making network calls. Checks YAML syntax, required fields, and strategy configuration. Does not verify URLs are accessible or files can be downloaded.

napt validate recipes/Google/chrome.yaml [OPTIONS]

napt discover

Discovers the latest version and downloads the installer. Uses version-based caching to skip downloads when versions haven't changed.

napt discover recipes/Google/chrome.yaml [OPTIONS]

napt build

Builds a complete PSADT package from a recipe and downloaded installer. Generates deployment scripts, applies branding, and creates versioned build directories.

napt build recipes/Google/chrome.yaml [OPTIONS]

napt package

Creates a .intunewin package for a recipe's build. The build directory is inferred automatically from the recipe's app ID. Without --version, packages the most recent build. Only one version is kept on disk per app; previous package directories are removed automatically.

napt package recipes/Google/chrome.yaml [OPTIONS]
napt package recipes/Google/chrome.yaml --version 130.0.6723.116

napt promote

Plans and applies ring-based promotion of published apps. promote plan computes which releases are ready to promote through deployment rings (per deployment.rings) and writes one plan file per app with work (state/plans/<app-id>.json); an app's stale plan file is removed when nothing is eligible for it. Read-only unless --reconcile is passed. Besides the fields apply acts on, each action carries reviewer context: a plain-English summary sentence, the Intune entry it touches, the version it displaces, and, for a promotion out of a held ring, when the release entered that ring and its bake threshold.

promote apply executes the plans against Intune: assigns install entries, promotes releases through rings, unassigns displaced releases, and retires them per deployment.retain_versions. It consumes every plan file in state/plans/ when any exist (removing each after its app applies fully) and plans fresh otherwise. Each app's plan is an independent unit: one app's failure keeps its plan file for retry and never blocks the others. Stale or already-applied actions are skipped with a warning, so re-running after a partial failure is safe. Assignments NAPT does not manage (admin-made groups, all-device targets, exclusions) are always preserved.

Both commands report assignment drift: every discrepancy between what deployment state says should be assigned and what Intune actually has: removed or changed NAPT assignments, unrecorded or foreign assignments on NAPT-managed apps, releases missing from the tenant, and stamped apps no state file references. An assignment NAPT has no record of making is classified by evidence: one that matches a currently configured target is reported as unrecorded (a lost apply writeback, which a later apply converges, or an admin pre-empting configured policy), while one matching no configured target is reported as unexpected (typically admin-made). Drift is warned about and never corrected. Apply checks automatically; plan checks with --check-drift (which needs Graph credentials; without the flag, plan stays fully offline).

Both commands also validate plan groups. Authenticated plan runs (--check-drift or --reconcile) resolve every group named in the computed plan and fail, writing no plan files, when one does not resolve, so a plan with a group typo never becomes a reviewable promotion PR. Apply preflights each app's actions the same way before executing any of them, so an unresolvable group fails that app with zero tenant mutations instead of stranding a half-applied plan; fix the configuration and re-plan. A dead group referenced only by stale or already-applied actions never blocks an app, so re-running after a partial failure stays safe. Offline plans skip validation (warning when they produce actions) and the apply preflight backstops whatever they produce.

Both commands also recover lost publication writebacks: when an upload succeeded but the state commit recording it never landed (a CI push rejected by branch protection, a crashed runner), the tenant holds a fully published release that state still lists as pending. Recovery re-derives the published record from the same provenance-stamp evidence idempotent upload uses, and only when every entry of the release has committed content; a partially published release is warned about instead, since only a publish re-run can finish it. Apply reconciles automatically; plan reconciles with --reconcile (which needs Graph credentials and, unlike the rest of plan, writes deployment state). Reconciliation runs before planning, so a recovered release is promotable in the same run.

napt promote plan [RECIPE_OR_DIR] [OPTIONS]
napt promote plan --check-drift --reconcile
napt promote apply [RECIPE_OR_DIR] [OPTIONS]
napt promote apply --plan-file state/plans/<id>.json

For the full review-gated CI setup (publish PRs, promotion PRs, and writeback commits) see Automate NAPT with GitHub Actions.

napt status

Shows deployment state across all apps: published version, pending release, and which version holds each ring. --format json for scripting.

napt status [OPTIONS]

A pending release whose version is lower than the published one is marked [DOWNGRADE] in the table and "pending_is_downgrade": true in the JSON. See Downgrades.

napt upload

Uploads the .intunewin package to Microsoft Intune via the Graph API. Uses the CI/CD environment credential when set, otherwise the session from napt auth login.

napt upload recipes/Google/chrome.yaml [OPTIONS]

napt auth

Manages the credential NAPT uses for Intune. See Authentication.

napt auth setup --tenant-id ID [OPTIONS]
napt auth login [--tenant-id ID] [--client-id ID] [--no-broker]
napt auth status
napt auth logout

Output modes

All commands support verbosity flags to control output detail:

Flag What it shows
(none) Clean output with step indicators (e.g., [1/4]) and progress
--verbose or -v All of the above, plus HTTP requests/responses, file operations, SHA-256 hashes, and configuration loading
--debug or -d All verbose output, plus full YAML config dumps (org/vendor/recipe/merged), backend selection details, and raw API responses

Use --verbose for normal troubleshooting and --debug when you need to see exactly what NAPT is doing internally.

Discovery strategies

Discovery strategies determine how NAPT finds installers and extracts version info.

Available strategies

Strategy Version Source Use Case How "unchanged" is detected
api_github Git tags GitHub-hosted releases Same tag as last run
api_json JSON API REST APIs with metadata Same version field as last run
url_download File metadata Fixed URLs, MSI or MSIX installers HTTP conditional request (ETag)
web_scrape Download page Vendors without APIs Same version on the page as last run

Note: For complete configuration examples and field documentation for each strategy, see Recipe Reference.

Decision guide

Use this flowchart to choose the right strategy:

flowchart TD
    Start{JSON API for<br/>version/download?}
    Start -->|Yes| JSON[api_json<br/>Fast version checks]
    Start -->|No| GitHub{Published via<br/>GitHub releases?}
    GitHub -->|Yes| GHRelease[api_github<br/>Reliable API, fast checks]
    GitHub -->|No| DirectURL{Fixed/stable<br/>download URL?}
    DirectURL -->|Yes| Static[url_download<br/>Must download to check]
    DirectURL -->|No| Scrape[web_scrape<br/>Scrape vendor page for link]

Recipe basics

A recipe is a YAML file that says how to discover, download, and package one application: a discovery strategy, PSADT scripts and variables, and optional Intune settings. Every field is documented in the Recipe Reference; worked examples for each strategy are in Common Tasks.

State management & downloads

NAPT keeps its records in two places:

  • The downloads folder (downloads/<app_id>/<version>/) - The installers themselves. Disposable: deleting it costs one full re-download per app and nothing else. Safe to gitignore.
  • Deployment state (state/deployment/<app_id>.json) - Authoritative per-app records of what NAPT has published to Intune (published) and what is awaiting publication (pending). Not regenerable. Written deterministically (fixed reading-order keys, no timestamps), so unchanged state produces byte-identical files and clean diffs. Commit these files to version control if you want an auditable record or a PR-based review workflow.

Skipping downloads

napt discover avoids downloading an installer it already has, which matters most for CI/CD running frequent scheduled checks. The downloads folder is the only thing it consults, so restoring that folder between runs (for example with actions/cache) is all a pipeline needs to do.

How the check works depends on the discovery strategy:

flowchart TD
    Start([napt discover]) --> Strategy{Strategy Type?}

    Strategy -->|Version-First<br/>api_github, api_json, web_scrape| CheckVersion[Check Version via API/Page]
    Strategy -->|File-First<br/>url_download| HaveFile{Last download<br/>still on disk?}

    CheckVersion --> SameVersion{Same version<br/>as last run?}
    SameVersion -->|Yes, and it matched<br/>the file's version| Skip1([Skip download<br/>Use that file])
    SameVersion -->|Yes, but the file<br/>disagreed| CheckETag
    SameVersion -->|No| Download1[Download File]

    HaveFile -->|Yes| CheckETag[Conditional request<br/>with saved ETag]
    HaveFile -->|No| Download2[Download File]
    CheckETag --> ETagResponse{Server<br/>Response?}
    ETagResponse -->|304 Not Modified| Skip2([Skip download<br/>Use that file])
    ETagResponse -->|200 OK Changed| Download2

    Download1 --> ReadVersion[Read version<br/>from MSI or MSIX]
    Download2 --> ReadVersion
    ReadVersion --> Pending[Record pending release]
    Skip1 --> Pending
    Skip2 --> Pending
    Pending --> Ready([Ready for napt build])

Every download writes downloads/<app_id>/.download.json, which records what that run resolved: the version the strategy reported, the server's ETag and Last-Modified, and the installer's own version, filename, and hash. The file is a hint, not a record: if it is missing or unreadable, NAPT downloads the full file and writes a new one.

Version-first strategies (api_github, api_json, web_scrape) learn a version before downloading. When it is the same version the last run reported, the installer on disk is reused without a request.

url_download cannot know the version without the file, so it asks the server whether the file changed. The next run sends the recorded ETag and Last-Modified back as a conditional request; a 304 Not Modified answer reuses the installer. The values are only sent while that installer is still on disk.

The installer's version is the version

The version a page or API reports is only the trigger for a download. Once the file is on disk, an MSI or MSIX installer reports its own version, and that is what NAPT records: it names the download folder, becomes the pending release, and later names the build and package folders, fills {{discovered_version}}, and is the version the detection script compares against on a device. An EXE carries no readable version, so for it the reported version is used as is.

When the two differ only in format (4.41.106 against 4.41.106.0), discover notes it in its log and treats them as the same version, because a device would too.

A real disagreement (the page says 2.1, the file is 2.0) means the vendor is serving an older file than it advertises, or the recipe's version_pattern captured the wrong value. The recorded release is truthful either way: 2.0 is what gets recorded. Discover also stops trusting the page's version as proof that nothing changed, since it was already wrong about this file once. Every run logs a warning naming both values and asks the server whether the file changed, using the saved ETag; a 304 Not Modified reuses the file, a 200 fetches whatever the server now serves. A server that sends no ETag or Last-Modified gets a full download each run instead. Once the page's version and the file's agree again, the request-free skip returns. If the warning never goes away, the recipe's version_pattern is the likely cause.

A version that goes down (a vendor pulling a release) is handled like any other change: the older version gets its own folder and its own download. NAPT never relabels an installer it already has.

Downgrades

NAPT treats a release as new when its installer differs from the published one, whichever direction the version moved. When a vendor replaces 2.0.0 with 1.9.0, napt discover records 1.9.0 as the pending release like any other, and nothing is published until you approve it.

What NAPT adds is a label, so the decision is made knowingly:

  • napt discover logs a warning that the pending release is lower than the published one.
  • napt status marks the app [DOWNGRADE] ("pending_is_downgrade": true in JSON).
  • The reference discover workflow reads that field and opens the PR as Publish <Name> 1.9.0 (downgrade from 2.0.0) with a warning at the top of the body.

The label is worked out each time from the two versions in deployment state; it is not stored.

Publishing a downgrade does not roll devices back. Detection and requirements scripts treat "this version or higher" as installed, so a device already on 2.0.0 reports the app as installed and is left alone. Only new installs receive 1.9.0. To move existing devices down, uninstall the newer version first.

How versions are ordered: NAPT uses the same comparison as the detection script on the device, so the label means "devices on the published version will not take this". Each . or - separated part contributes its leading digits, and a part with no leading digits counts as 0. A prerelease tag is therefore not ranked (1.0-rc1 equals 1.0), and a v prefix turns the first number into 0 (v2.0 reads as 0.0). Capture only the numeric version in the recipe's version_pattern to avoid both.

Deployment state

Each app gets its own file, state/deployment/<app_id>.json, so concurrent changes to different apps never conflict and each file's diff is scoped to one app. Every file carries a schemaVersion (currently 1); NAPT refuses files whose schemaVersion is missing or unsupported. A file names its app once at the top with app_id (which must match the filename; a copied or renamed file is rejected) and the recipe's display name (refreshed on every save), then holds five sections:

  • published - The release currently in Intune, with its SHA-256 hash and Intune app IDs. Null until the first upload. Publishing uploads the release without assigning it; napt promote deploys it through the rings afterwards.
  • install_assigned - The release the install entry is currently assigned to (the result of a promotion plan's assign action).
  • pending - The discovered release awaiting publication (version, download URL, SHA-256 hash). A single slot: a newer discovery replaces an unpublished candidate (newest wins), and discovering the already-published release clears it. Identity is the SHA-256 hash, so a vendor re-release of the same version with a different binary counts as new.
  • rings - Which version currently holds each deployment ring, with the timestamp it entered (written by napt promote apply).
  • retained - Displaced versions kept in Intune for rollback per deployment.retain_versions (written by napt promote apply).

Keys follow reading order (lifecycle order at the top level, version first and hashes last inside blocks) because these files are what a publish PR diff shows its reviewer. napt discover records the pending candidate. Later pipeline stages consume it.

Promotion plan files

napt promote plan evaluates ring eligibility as a pure function of deployment state, configuration, and the clock, and writes the result as one file per app: state/plans/<app-id>.json. A plan file names its app once at the top and lists actions of two types, one per Intune entry: promote moves a release one ring forward (from_ring: null marks a first rollout into the first ring), and assign points new installs at the release. Every action opens with a plain-English summary and carries the details behind it (the entry touched, the version it displaces, bake timestamps) so the file diff reads on its own in review:

{
  "schemaVersion": 1,
  "app_id": "napt-chrome",
  "name": "Google Chrome",
  "actions": [
    {
      "summary": "Promote 140.0.7339.128 from pilot to production, displacing 139.0.7258.155; it has held pilot since 2026-07-14 (threshold: 2 days).",
      "type": "promote",
      "entry": "update",
      "version": "140.0.7339.128",
      "displaces": "139.0.7258.155",
      "from_ring": "pilot",
      "from_ring_entered_at": "2026-07-14T07:12:03+00:00",
      "promote_after_days": 2,
      "ring": "production",
      "groups": ["Production Devices"],
      "sha256": "6ff02fd8a4..."
    }
  ]
}

An app's plan file exists exactly when that app has eligible actions: a plan run that finds nothing for an app removes its stale plan file. Each file's git status is therefore the per-app CI signal that a promotion review is needed; there are no special exit codes (napt always exits 0 on success, 1 on error). Plan output is deterministic, so re-running plan against unchanged state produces byte-identical files. napt promote apply executes each plan as an allowlist (entries that no longer validate against current state are skipped, never improvised) and removes each file after its app applies fully. To hold one app's promotions during review, delete its plan file; the other apps' plans are unaffected, and the next plan run re-proposes whatever is still eligible.

Default behavior (stateful)

# Deployment state tracking enabled by default
napt discover recipes/Google/chrome.yaml

# Creates/updates: state/deployment/napt-chrome.json

Stateless mode

# Leave deployment state alone for one-off checks
napt discover recipes/Google/chrome.yaml --stateless

# Deployment state is neither read nor written, so no pending release is recorded
# Installers already in the downloads folder are still reused

Configuration layers

NAPT layers configuration so you don't repeat settings across recipes. All defaults live in code; configuration files are optional layers on top.

How configuration works

Code defaults (always complete)     <- baseline, ships with napt
    |
./defaults/org.yaml                 <- organization defaults (optional)
    |
./defaults/vendors/<Vendor>.yaml    <- vendor defaults (optional)
    |
parent recipe                       <- named by the recipe's parent field (optional)
    |
recipe.yaml                         <- the app itself, wins over everything

Key principles:

  • Code provides complete, working defaults for all settings
  • Config files only set what you need to change
  • Missing fields always fall back to code defaults
  • Old configs never break when NAPT adds new features
  • Any setting can be set at any layer: org, vendor, parent, or recipe
  • Dicts merge key by key; lists and scalars replace the value beneath them, so a recipe that sets deployment.rings replaces the whole list

The configuration layers

  1. Organization defaults (defaults/org.yaml) - Base settings for all apps. Optional; only needed if you want to customize settings organization-wide. Contains PSADT settings, update policies, and build configuration.

  2. Vendor defaults (defaults/vendors/<Vendor>.yaml) - Vendor-specific settings. Optional; only loaded if vendor is detected (e.g., Google-specific settings).

  3. Parent recipe (the parent field) - Another recipe merged beneath this one. Optional; lets several recipes share a base without repeating it. A parent cannot declare its own parent. See parent for the field and the file naming convention.

  4. Recipe configuration (recipes/<Vendor>/<app>.yaml) - App-specific settings. Always required; defines the specific app and wins over every other layer.

Example

# defaults/org.yaml
psadt:
  release: "latest"
  app_vars:
    AppVendor: "Unknown"
# defaults/vendors/Google.yaml
psadt:
  app_vars:
    AppVendor: "Google LLC"
# recipes/Google/chrome.yaml
name: "Google Chrome"
id: "napt-chrome"
discovery:
  strategy: url_download
  url: "https://dl.google.com/..."
psadt:
  release: "4.1.7"   # overrides org default of "latest" for this recipe only
# AppVendor will be "Google LLC" (from vendor defaults)

Directory flag defaults

All directory flags follow the same pattern: CLI flag overrides config; config overrides the built-in default.

Each command reads from the previous command's output directory and writes to its own:

Command Flag Purpose Config key Built-in default
napt discover --output-dir Where to save downloaded installers directories.discover downloads
napt discover --state-dir Per-app deployment state (<dir>/deployment/) directories.state state
napt build --state-dir Where to read the release to build (<dir>/deployment/) directories.state state
napt promote --state-dir Deployment state and plan files directories.state state
napt status --state-dir Deployment state to summarize (no config lookup) - state
napt build --downloads-dir Where to find the installer directories.discover downloads
napt build --output-dir Where to save builds directories.build builds
napt package --builds-dir Where to find the build directories.build builds
napt package --output-dir Where to save packages directories.package packages

Input and output share a config key across adjacent commands: discover --output-dir and build --downloads-dir both read from directories.discover, so the output of one is automatically the input of the next without extra configuration.

One additional directory has no CLI flag: directories.icons (default icons) holds app icons written by napt build and read by napt upload. See App icons.

To change the defaults org-wide, add to defaults/org.yaml:

directories:
  discover: "artifacts/downloads"  # used by both discover and build
  build: "artifacts/builds"        # used by both build and package
  package: "artifacts/packages"
  icons: "artifacts/icons"         # written by build, read by upload
  state: "deployment-state"        # per-app deployment state (authoritative)

Any CLI flag still overrides the config value for that single run:

# Uses config default (or built-in if not configured)
napt discover recipes/Google/chrome.yaml

# Overrides for this run only
napt discover recipes/Google/chrome.yaml --output-dir /tmp/downloads
napt build recipes/Google/chrome.yaml --downloads-dir /tmp/downloads

To pin the IntuneWinAppUtil.exe release napt package uses, see intunewin.release.

Cross-platform support

NAPT is a Windows tool for Microsoft Intune packaging. Develop on any platform, package on Windows.

Platform compatibility matrix

Platform Discover & Download Build Package
Windows
Linux ⚫ Windows Only
macOS ⚫ Windows Only

Why Windows for packaging?

The napt package command uses Microsoft's IntuneWinAppUtil.exe, which is a Windows-only .NET application. This is the official tool for creating .intunewin packages.

Mixed platform workflow

Running everything on Windows is the simple case. To develop on Linux or macOS and package on Windows:

# On Linux/macOS: Discovery and build
napt discover recipes/Google/chrome.yaml
napt build recipes/Google/chrome.yaml

# Transfer build directory to Windows (e.g., via shared storage)
# On Windows: Package
napt package recipes/Google/chrome.yaml

Best practices

Recipe organization

Organize recipes by vendor: recipes/<Vendor>/<app>.yaml. NAPT detects the vendor from the recipe's parent directory name (falling back to psadt.app_vars.AppVendor) and loads defaults/vendors/<Vendor>.yaml if it exists.

Scripting

All commands return standard exit codes (0 = success, 1 = error), making them easy to use in automation scripts:

if napt discover recipes/Google/chrome.yaml; then
    napt build recipes/Google/chrome.yaml
fi

Troubleshooting

For discovery failures (unknown strategy, version extraction, rate limits, network errors, corrupted state) and MSI extraction on Linux/macOS, see Troubleshoot discovery failures.