A PowerShell-based deployment toolkit for Microsoft 365 Apps for Enterprise, Visio, Project, and language packs — purpose-built for Microsoft Intune Win32 app delivery on Windows Autopilot devices.
🌐 Live interactive version: https://haakonwibe.github.io/m365apps-deploy/
The Intune deployment community has solved most of the hard parts of shipping Office to Autopilot devices — detection, logging, language packs, uninstall semantics — across a generous body of public scripts, blog posts, and Microsoft Learn samples that this toolkit stands on. What was missing for me was a single consolidated codebase that covered all of it. This toolkit pulls those lessons together so your team can own, fork, and audit one maintainable repo instead of assembling and reconciling several for every new tenant.
| ❌ Failure mode | ✅ This toolkit |
|---|---|
| DisplayName-match detection false-positives on legacy MSI | Reads HKLM\...\ClickToRun\Configuration\ProductReleaseIds |
setup.exe returns 0 but nothing installed |
Post-install registry verification, exits 17002 on silent failure |
Install fragments logs across %TEMP%, ProgramFiles, … |
One folder: C:\ProgramData\M365AppsDeploy\Logs\ |
| Raw text logs, no CMTrace support | Every line is CMTrace/OneTrace ready |
MatchInstalled causing unsupported-language failures |
Per-product language matrix validated before setup.exe |
| Visio uninstall nukes Office by accident | <Remove All="FALSE"> with explicit product scoping |
- ✨ Features
- 📐 Architecture at a glance
- ⚙️ Requirements
- 🔧 Two binaries you must supply
- 🗂️ Repository layout
- 🚀 Quickstart
- ⚙️ Optional build parameters
- 🧩 Products & commands
- 📂 Log layout
- 📚 Documentation
- 🧪 Tests
- 🙅 Out of scope
- 📄 License & versioning
- 🧱 Single-language baseline — pick the base UI language at build
time (
-Language sv-se; defaulten-us); per-group language overlays ship as additional Win32 apps. - 🪵 CMTrace-compatible logs — every script, session-header to footer, severity 1/2/3, PID, user context, and source-line trace.
- 🔁 Rotation at 10 MB; old log becomes
.log.old. - ✅ Pre-flight checks — elevation, pending reboot, disk space.
Concurrency is delegated to ODT's own mutex (exit 1618 is surfaced
accurately via
Get-ODTExitCodeResult). - 🔍 Authoritative detection — no DisplayName scans; reads the
Click-to-Run
Configurationhive directly. - 🧪 Catches ODT silent failures — post-install registry verification
turns "
setup.exereturned 0 but nothing installed" into a loud17002exit. - 🌍 Language matrix validation — rejects unsupported
(LanguageID, TargetProduct)combos before ODT sees them. - 🎁 Add-on friendly — Visio and Project install only on top of an existing Office install, with channel / architecture sanity checks.
- 🧰 Evergreen OR bundled
setup.exe, your choice per deploy. - 🏷️ Per-product scoped uninstalls — removing Visio leaves Office and Project untouched.
- 📦 Ready-to-package Build script wraps everything into
.intunewinfiles, generates per-language detection wrappers, and emits a per-productIntuneConfig.mdcheat-sheet for the Intune UI. - 🏷️ OCT-style optional tokens — set
-CompanyName(or persist it inbuild-config.json) and the value is substituted into staged XMLs at build time. Leave it unset and the corresponding line is removed from the XML — noYour Companyplaceholder ever leaks into a production install. - 🌐 Friendly channel names in logs — registry CDN GUIDs are
resolved to canonical ODT names (
MonthlyEnterprise,Current,PerpetualVL2021, …) in every log line. - 🧪 Fast lab iteration — same build script stages a
copy-to-VM-ready tree at
Build\Staging\<Product>\(use-StagingOnly), skipping the Intune upload-assign loop. Seedocs/local-testing.md. - ✔️ Pester 5 unit tests (577 tests) + an end-to-end lab harness.
┌─────────────────────────────────────────┐
│ Microsoft Intune (Win32 apps) │
└────────────────┬────────────────────────┘
│ (install / uninstall / detect commands)
▼
┌─────────────┬─────────────┬─────────────┬───────────────┐
│ M365Apps │ Visio │ Project │ LanguagePacks │
│ Install-* │ Install-* │ Install-* │ Install-* │
│ Uninstall-* │ Uninstall-* │ Uninstall-* │ Uninstall-* │
│ Detect-* │ Detect-* │ Detect-* │ Detect-* │
│ + base.xml │ + base.xml │ + base.xml │ + template.xml│
│ + Tools/ │ + Tools/ │ + Tools/ │ + Tools/ │
└──────┬──────┴──────┬──────┴──────┬──────┴──────┬────────┘
│ │ │ │
└─────────────┴──────┬──────┴─────────────┘
▼
┌──────────────────────────────┐
│ Common/ │
│ ODTLogging (CMTrace) │
│ ODTPrerequisites (pre-flight)│
│ ODTOfficeState (detection) │
│ ODTInvoke (install) │
│ ODTLanguages (matrix) │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ setup.exe (ODT) /configure │
└──────────────┬───────────────┘
▼
┌────────────────────────────────────────────────┐
│ C:\ProgramData\M365AppsDeploy\Logs\ │
│ • <Product>-Install.log │
│ • <Product>-Uninstall.log │
│ • <Product>-Detection.log │
│ (ODT's native logs stay in %TEMP% - we don't │
│ redirect; see docs/troubleshooting.md.) │
└────────────────────────────────────────────────┘
Full details in docs/architecture.md.
| Requirement | Version / notes |
|---|---|
| 🖥️ Client OS | Windows 10 / 11, 64-bit |
| 🐚 PowerShell | 5.1 (ships with Windows). 7+ also works |
| ☁️ Intune tenant | Win32 app delivery enabled |
| 🪪 Enrolment | Autopilot or direct MDM enrolment |
📦 ODT (setup.exe) |
Supplied per-product, or fetched at runtime |
🛠️ IntuneWinAppUtil |
Required for building .intunewin packages |
⚠️ Neither is committed to this repo. Both must be downloaded before building packages.
💡 Easy way: run
.\Source\Update-Tooling.ps1— it downloads both binaries from Microsoft, Authenticode-verifies the signatures, and places them at their canonical locations (Source\setup.exeandBuild\IntuneWinAppUtil.exe). It also writes a gitignoredSource\tooling-versions.jsonmanifest recording version + source URL
- timestamp. The manual instructions below are the fallback for environments where scripted downloads aren't allowed.
- 🔗 https://www.microsoft.com/en-us/download/details.aspx?id=49117
- Extract
setup.exeand drop it once intoSource\setup.exe.Build\Build-IntuneWinPackages.ps1stages it into every product'sTools\folder at build time, so each.intunewincarries a copy. - 🔀 Per-product override: want a specific ODT build for one
product? Drop it into
<Product>\Tools\setup.exeand run the build with-SetupExeSource ''. - ✨ Evergreen: pass
-UseEvergreenSetupto the install scripts to pull a fresh copy from Microsoft at run-time instead of bundling.
- 🔗 https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool
- Place at
Build\IntuneWinAppUtil.exe, or pass-IntuneWinAppUtilPathtoBuild\Build-IntuneWinPackages.ps1.
The repo has three intentionally distinct layers for each product. Don't try to "deduplicate" — each layer serves a different purpose:
m365apps-deploy/
├── M365Apps/ Visio/ Project/ LanguagePacks/ <- Layer 1: AUTHORING
│ ├── Install-*.ps1 The only files you edit by hand.
│ ├── Uninstall-*.ps1 Source of truth for the toolkit.
│ ├── Detect-*.ps1
│ └── Configurations/*.xml {{CompanyName}} placeholders here.
├── Common/ (shared across products at runtime)
│ └── ODT*.psm1
├── Source/ Shared inputs folder.
│ ├── Update-Tooling.ps1 One-shot fetcher for the two
│ │ Microsoft binaries (downloads,
│ │ Authenticode-verifies, places).
│ ├── README.md How to populate this folder.
│ └── setup.exe (gitignored Microsoft binary)
├── Build/ Build pipeline (committed text outputs)
│ ├── Build-IntuneWinPackages.ps1
│ ├── Invoke-ProductStaging.ps1
│ ├── IntuneWinAppUtil.exe (gitignored Microsoft binary)
│ ├── Staging/<Product>/ <- Layer 2: STAGING (post-substitution)
│ │ ├── (Layer-1 files copied + token-substituted)
│ │ ├── Configurations/ {{CompanyName}} resolved or stripped.
│ │ ├── Tools/setup.exe (gitignored Microsoft binary)
│ │ └── Common/ Bit-for-bit what Intune extracts on
│ │ a client - browsable on GitHub.
│ └── Output/<Product>/ <- Layer 3: ADMIN-FACING ARTEFACTS
│ ├── *.intunewin (gitignored - encrypted binary blob)
│ ├── <Product>-IntuneConfig.md Cheat-sheet for the Intune wizard.
│ └── DetectionScripts/ Ready-to-upload detection scripts;
│ one per simple product, one per
│ language for LanguagePacks.
├── docs/ Architecture, intune-deployment,
│ customization, troubleshooting,
│ language-matrix, local-testing.
├── Tests/Pester/ 94 unit tests (all mocked).
├── build-config.example.json Template for build-config.json
│ (gitignored, holds your CompanyName).
└── CHANGELOG.md
Why three layers? Layer 1 is what you edit. Layer 2 captures exactly what runs on the client — committed so reviewers can read the deployed scripts on GitHub without cloning. Layer 3 is what an admin uploads to Intune — the package, the cheat-sheet, the detection script. Each layer is regenerated from Layer 1 by the build pipeline; you should never edit Layers 2 or 3 directly.
The only excluded artefacts are Microsoft binaries (setup.exe,
IntuneWinAppUtil.exe) and the encrypted .intunewin blobs.
Everything else is committed text.
# 1. Clone the repo.
git clone https://github.com/haakonwibe/m365apps-deploy
cd m365apps-deploy
# 2. Drop setup.exe into Source\ (one copy, shared), and drop
# IntuneWinAppUtil.exe into Build\.
# 3. Build everything. Produces:
# Build\Staging\<Product>\ <- staged tree (retained for lab testing,
# committed to the repo for GitHub browsing)
# Build\Output\<Product>\*.intunewin <- Win32 package (binary, gitignored)
# Build\Output\<Product>\DetectionScripts\ <- ready-to-upload detection scripts
# Build\Output\<Product>\<Product>-IntuneConfig.md
# <- cheat-sheet for the Intune UI
.\Build\Build-IntuneWinPackages.ps1
# Optional: bake an org name into this build's staged XMLs.
# .\Build\Build-IntuneWinPackages.ps1 -CompanyName "Contoso Ltd"
# Or persist it in build-config.json (gitignored). See "Optional build
# parameters" below.
# 4. (Optional) run the Pester unit tests.
Invoke-Pester -Path .\Tests\Pester
# 5. Upload each .intunewin from Build\Output\<Product>\ to Intune.
# Use the matching <Product>-IntuneConfig.md for the exact install /
# uninstall / detection / dependency values to paste into the
# Intune Win32 app wizard.
# Staging-only (for lab-VM iteration, no .intunewin produced):
# .\Build\Build-IntuneWinPackages.ps1 -StagingOnly
# See docs\local-testing.md for the snapshot -> copy -> install -> revert flow.The build pipeline supports optional org-specific values that get
substituted into staged Configuration XMLs at build time. Every value
is optional. Building with no values set produces a fully generic
public-toolkit build with no organisation-specific data baked in — the
corresponding lines are simply removed from the staged XML, mirroring
how config.office.com (OCT) treats a blank field.
Resolution order:
- CLI parameter (highest priority).
build-config.jsonat the repo root (gitignored). Copybuild-config.example.jsontobuild-config.jsonand fill in the values you want to persist across builds.- Unset (no substitution; line is removed during staging).
| Token | Mode | CLI parameter | What it controls | If unset / null |
|---|---|---|---|---|
CompanyName |
scalar | -CompanyName "..." |
<Setup Name="Company" Value="..."/> in M365 Apps / Visio / Project base XMLs. |
The <Setup> line is dropped, and the surrounding <AppSettings> block is stripped if it becomes empty. |
ExcludedApps |
array (replace) | -ExcludedApps Access,Bing,Lync (etc.) |
The default <ExcludeApp> set in M365Apps/Configurations/m365apps-base.xml (the <Product> block). |
The seven-app modern default is preserved as-is from the XML: Access, Bing, Groove, Lync, OneDrive, Publisher, Teams. |
Language |
scalar | -Language sv-se (etc.) |
<Language ID="..."/> in M365Apps/Configurations/m365apps-base.xml — the M365 Apps base UI language. Validated against the supported-language matrix at build time, so typos fail the build instead of producing a 17002 at install time. |
Defaults to en-us (an unset value substitutes the default rather than dropping the line, since an empty <Language> would make the <Product> element invalid for ODT). |
# Example 1: bake a company name into this build's staged XMLs.
.\Build\Build-IntuneWinPackages.ps1 -CompanyName "Contoso Ltd"
# Example 2: re-include Access (drop it from the exclusion list).
.\Build\Build-IntuneWinPackages.ps1 -ExcludedApps Bing,Groove,Lync,OneDrive,Publisher,Teams
# Example 3: install every Office app (no exclusions).
.\Build\Build-IntuneWinPackages.ps1 -ExcludedApps @()
# Or persist values in build-config.json (gitignored, never committed):
Copy-Item build-config.example.json build-config.json
# edit build-config.json
.\Build\Build-IntuneWinPackages.ps1ℹ️
ExcludedAppsis an array, replace-not-supplement: an explicit value (even an empty array) overrides the entire default set. If you want the default plus one extra, list every entry you want. Unknown IDs fail the build with a clear error — ODT silently ignores them, which would otherwise ship a working-looking package that doesn't actually exclude what you asked for.
Adding a new token later is one line in the registration table at the
top of Build\Invoke-ProductStaging.ps1 (Get-DefaultBuildTokens)
plus an optional CLI parameter on Build-IntuneWinPackages.ps1. The
substitution engine hard-fails on any {{...}} placeholder it finds
in source XMLs that is not registered, so typos surface at build time
rather than runtime.
Paste these into Intune's Install command and Uninstall command fields. Detection is a custom script — use the files in the same folder.
| 🔹 Product | ⏹️ Uninstall | 🔎 Detection | |
|---|---|---|---|
| M365 Apps | powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-M365Apps.ps1 |
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Uninstall-M365Apps.ps1 |
Detect-M365Apps.ps1 |
| Visio | powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-Visio.ps1 |
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Uninstall-Visio.ps1 |
Detect-Visio.ps1 |
| Project | powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-Project.ps1 |
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Uninstall-Project.ps1 |
Detect-Project.ps1 |
| Language Pack | powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-LanguagePack.ps1 -LanguageID nb-no |
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Uninstall-LanguagePack.ps1 -LanguageID nb-no |
Detect-LanguagePack-nb-no.ps1 |
💡 Intune doesn't pass parameters to detection scripts. The build generates one self-contained wrapper per supported Office language at
Build\Output\LanguagePacks\DetectionScripts\Detect-LanguagePack-<LanguageID>.ps1— upload the one that matches the language being deployed. Seedocs/intune-deployment.mdfor the pattern.
ℹ️ Standalone Proofing Tools is not supported. Standalone
ProofingToolsinstalls are unreliable on current M365 Apps (the registry stub appears but Office's language UI doesn't recognise the proofing). Install the full Language Pack for the target language instead — Language Packs include proofing as part of the package.
🌍 Visio / Project always install in
en-US. The base install for both products is a constant en-US — independent of the base Microsoft 365 Apps culture. Additional UI languages for Visio / Project ship as separate Win32 apps via theLanguagePacks/workflow on top of the en-US base, exactly the same model the toolkit uses for Microsoft 365 Apps's non-primary languages. One base install per product, one canonical mechanism for adding languages on top.Why constant en-US rather than culture-matching: Microsoft 365 Apps ships three cultures (
en-GB,fr-CA,es-MX) that Visio and Project don't carry as separate base-culture installs (see the supported-languages doc, footnote [1] under Visio/Project), so naïve inheritance trips ODT'sBOOTSTRAPPER_PREREQ-UnsupportedCulturesOnUnsupportedProductsprerequisite (exit 1603, no install attempt). LP installs flipClientCultureover a device's lifetime; the per-product matrix is partly aspirational; distinguishing original install culture from current display preference is more state than a Win32 install script should track. Always-en-US plus LP overlays sidesteps every one of those failure modes.Language-pack side note: if you deploy the
en-gb,fr-ca, ores-mxlanguage pack on top of Visio / Project, those products show the language as installed in their Language Preferences UI but render inen-US/fr-FR/es-ESstrings (Microsoft doesn't ship a Visio / Project UI pack for those three cultures). Proofing still works. Seedocs/language-matrix.mdfor the full behaviour table.
┌────────────────────────┐
│ Microsoft 365 Apps │ ← base install (no dependencies)
└───┬────────┬────────┬──┘
│ │ │
▼ ▼ ▼
┌─────┐ ┌────────┐ ┌─────────────────┐
│Visio│ │Project │ │ Language Pack │ (one per language)
└─────┘ └────────┘ └─────────────────┘
In Intune, mark M365 Apps as a dependency with Automatically install = Yes on every add-on app.
Everything the toolkit does lands in one place:
C:\ProgramData\M365AppsDeploy\Logs\
├── 📄 M365Apps-Install.log
├── 📄 M365Apps-Detection.log
├── 📄 M365Apps-Uninstall.log
├── 📄 Visio-Install.log
├── 📄 Visio-Detection.log
├── 📄 Project-Install.log
└── 📄 LanguagePack-O365ProPlusRetail-nb-no-Install.log
# ODT's native logs are NOT redirected — they live in %TEMP%.
# See docs/troubleshooting.md if you need them for deep debugging.
Every line is CMTrace-compatible:
<![LOG[Install succeeded.]LOG]!><time="16:47:30.315+120" date="04-24-2026"
component="Install-M365Apps" context="SYSTEM" type="1" thread="23856"
file="Install-M365Apps.ps1:145">
Open with:
- 🧰 CMTrace.exe (ships with ConfigMgr / MDT)
- 🧰 OneTrace
- 📝 Any plain-text editor (degrades gracefully)
🔒 If
C:\ProgramData\is locked down in your org, override with-LogPathon every script — consistently across install, uninstall, and detection commands. Seedocs/customization.md.
| 📘 Guide | What it covers |
|---|---|
docs/architecture.md |
Design decisions, module responsibilities, flow diagrams |
docs/intune-deployment.md |
Step-by-step Intune Win32 upload procedure |
docs/local-testing.md |
Iterate on a lab VM from Build\Staging\ — no Intune loop |
docs/customization.md |
Adapting the toolkit to a new organisation |
docs/troubleshooting.md |
Known failure modes, exit codes, diagnostics |
docs/language-matrix.md |
Per-product supported language codes |
CHANGELOG.md |
Version history (SemVer) |
Invoke-Pester -Path .\Tests\Pester- ✅ 14 test files, 577 tests, all mocked — no real registry or ODT calls.
- Covers: log line shape, rotation, path resolution, prerequisite wrappers, registry detection helpers, language-matrix validation, exit-code translation, channel GUID -> name resolution, build-time token substitution, per-language detection-script generation, staged-package integrity gate.
# Disposable VM only. Installs + uninstalls real Office bits.
.\Tests\Invoke-DeploymentTest.ps1 -UseEvergreenSetupDrives the full install → detect → uninstall lifecycle for every product and prints a PASS/FAIL summary table.
This toolkit deliberately does not build:
- ❌ Entra dynamic group creation
- ❌ Intune app upload automation (use the portal or Graph API)
- ❌ License / service plan assignment
- ❌ Autopilot profile management
- ❌ Office activation troubleshooting
- ❌ End-user UI (all installs are silent by design)
- ❌ SCCM / ConfigMgr integration
Licensing of the Office bits is handled externally via Entra dynamic groups that assign Win32 apps to the right users. That separation is intentional — licensing logic is environment-specific and decouples cleanly from install mechanics.
- 📄 License: MIT
- 🔖 Versioning: Semantic Versioning — see
CHANGELOG.md. - 🧷 Current version:
1.0.8
Built for IT teams who'd rather own one maintainable toolkit than re-stitch a deployment pipeline every Patch Tuesday.
