Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Create an MECM Package and Program from PowerShell

Turn "wrap this script in a package and push it to the DPs" into one command, with a dry run before anything gets created.

AT A GLANCENew-CMScriptPackage.ps1
What it does
Creates a Configuration Manager package with a single program for a script or installer you've already dropped on a share, then starts content distribution to the distribution point groups you name.
Requires
  • Windows PowerShell 5.1 (the ConfigMgr module is happiest there)
  • Configuration Manager console installed, for the ConfigurationManager module
Permissions
A ConfigMgr role that can create packages and distribute content (Application Administrator or Operations Administrator both work), plus read access to the source share.
Runs on
Any machine with the Configuration Manager console
Tested
Parse-checked and dry-run with mocked cmdlets in PowerShell 7.4

Applications get all the attention in ConfigMgr, but plenty of jobs still fit a plain old package better. A one-off remediation script. A registry fix. Something that runs once and never needs a detection method. Building those by hand is five wizard pages, and it's easy to fat-finger the command line or forget to distribute the content before you deploy.

This script does the whole thing from one command: check the content is where you say it is, make sure the package doesn't already exist, create it, add the program, and send it to your distribution point groups. Everything is a parameter, so the same script works for every package you build.

The original version of this post had the site code, share paths and package name baked right into the top of the script, and it called New-CMProgram with parameters that don't actually exist. This one is rebuilt from scratch, uses the real parameter names, and supports -WhatIf.

New-CMScriptPackage.ps1Download
<#
.SYNOPSIS
    Creates a Configuration Manager package and program for a script, then sends it to your distribution points.
.DESCRIPTION
    Connects to a ConfigMgr (MECM) site, checks the content folder, creates a legacy package with a
    single standard program, and starts content distribution to one or more distribution point groups.
    If a package with the same name already exists, the script stops rather than creating a duplicate.
    Supports -WhatIf.
.PARAMETER SiteCode
    Your three-character site code, for example ABC.
.PARAMETER ProviderMachineName
    The SMS Provider server. Defaults to this computer.
.PARAMETER PackageName
    Name for the new package.
.PARAMETER SourcePath
    UNC path to the package content. It must already contain the file your command line runs.
.PARAMETER CommandLine
    The program's command line, relative to the package content.
.PARAMETER DistributionPointGroupName
    One or more distribution point groups to send the content to. Leave it off to skip distribution.
.EXAMPLE
    .\New-CMScriptPackage.ps1 -SiteCode ABC -PackageName 'Set Power Plan' -SourcePath '\\sccm01\Sources\Scripts\PowerPlan' -CommandLine 'powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Set-PowerPlan.ps1' -DistributionPointGroupName 'All DPs'
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9]{3}$')][string]$SiteCode,
    [string]$ProviderMachineName = $env:COMPUTERNAME,
    [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$PackageName,
    [string]$Description = '',
    [Parameter(Mandatory)][ValidatePattern('^\\\\')][string]$SourcePath,
    [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CommandLine,
    [ValidateLength(1, 50)][string]$ProgramName = 'Install',
    [ValidateSet('OnlyWhenUserIsLoggedOn', 'WhetherOrNotUserIsLoggedOn', 'OnlyWhenNoUserIsLoggedOn')]
    [string]$ProgramRunType = 'WhetherOrNotUserIsLoggedOn',
    [ValidateSet('Normal', 'Minimized', 'Maximized', 'Hidden')][string]$RunType = 'Hidden',
    [string[]]$DistributionPointGroupName
)

$ErrorActionPreference = 'Stop'

# Check the content before we touch ConfigMgr. The site server reads this path, not your PC.
if (-not (Test-Path -LiteralPath $SourcePath -PathType Container)) {
    throw "Source path '$SourcePath' doesn't exist or isn't reachable."
}
$firstFile = ($CommandLine -split '\s+' | Where-Object { $_ -match '\.(ps1|cmd|bat|exe|msi|vbs)"?$' } | Select-Object -Last 1)
if ($firstFile) {
    $candidate = '{0}\{1}' -f $SourcePath.TrimEnd('\'), ($firstFile.Trim('"') -replace '^\.\\', '')
    if (-not (Test-Path -LiteralPath $candidate)) { Write-Warning "Couldn't find '$candidate'. Check the command line matches the content." }
}

# Load the ConfigMgr module from the console install and map the site drive.
if (-not (Get-Module ConfigurationManager)) {
    if (-not $env:SMS_ADMIN_UI_PATH) { throw 'The Configuration Manager console is not installed on this machine.' }
    Import-Module (Join-Path $env:SMS_ADMIN_UI_PATH '..\ConfigurationManager.psd1')
}
if (-not (Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue)) {
    New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $ProviderMachineName | Out-Null
}

Push-Location "$($SiteCode):\"
try {
    if (Get-CMPackage -Name $PackageName -Fast) {
        throw "A package named '$PackageName' already exists. Pick another name or update the existing one."
    }

    if ($PSCmdlet.ShouldProcess($PackageName, "Create package from $SourcePath")) {
        $package = New-CMPackage -Name $PackageName -Description $Description -Path $SourcePath
        Write-Verbose "Created package $($package.PackageID)"

        $programArgs = @{
            PackageId           = $package.PackageID
            StandardProgramName = $ProgramName
            CommandLine         = $CommandLine
            ProgramRunType      = $ProgramRunType
            RunType             = $RunType
            RunMode             = 'RunWithAdministrativeRights'
        }
        New-CMProgram @programArgs | Out-Null
        Write-Verbose "Added program '$ProgramName'"

        $distributed = @()
        foreach ($group in $DistributionPointGroupName) {
            try {
                Start-CMContentDistribution -PackageId $package.PackageID -DistributionPointGroupName $group
                $distributed += $group
            }
            catch {
                Write-Warning "Distribution to '$group' failed: $($_.Exception.Message)"
            }
        }

        [pscustomobject]@{
            PackageName   = $PackageName
            PackageId     = $package.PackageID
            Program       = $ProgramName
            CommandLine   = $CommandLine
            SourcePath    = $SourcePath
            DistributedTo = $distributed -join ', '
        }
    }
}
finally {
    Pop-Location
}

Parameters

ParameterTypeDefaultWhat it's for
-SiteCodestring—Your three-character site code, like ABC.
-ProviderMachineNamestring$env:COMPUTERNAMEThe server running the SMS Provider. Usually your primary site server.
-PackageNamestring—What to call the package. The script refuses to create a duplicate.
-Descriptionstring—Optional description that shows in the console.
-SourcePathstring—UNC path to the content folder. Has to be UNC, because the site server reads it, not your workstation.
-CommandLinestring—The program's command line, relative to the content folder.
-ProgramNamestringInstallName of the program inside the package.
-ProgramRunTypestringWhetherOrNotUserIsLoggedOnWhen the program is allowed to run. The other choices are OnlyWhenUserIsLoggedOn and OnlyWhenNoUserIsLoggedOn.
-RunTypestringHiddenWindow state while it runs. Hidden is right for most scripts.
-DistributionPointGroupNamestring[]—One or more DP groups to send the content to. Leave it off if you'd rather distribute later.

Run it

See what it would do first. Nothing gets created.

.\New-CMScriptPackage.ps1 -SiteCode ABC -PackageName 'Set Power Plan' -SourcePath '\\sccm01\Sources\Scripts\PowerPlan' -CommandLine 'powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Set-PowerPlan.ps1' -WhatIf

Create it and send it to your DP group in one go.

.\New-CMScriptPackage.ps1 -SiteCode ABC -PackageName 'Set Power Plan' -SourcePath '\\sccm01\Sources\Scripts\PowerPlan' -CommandLine 'powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Set-PowerPlan.ps1' -DistributionPointGroupName 'All DPs'

A batch-file fix that should only run when nobody's signed in.

.\New-CMScriptPackage.ps1 -SiteCode ABC -PackageName 'Reset Print Queue' -SourcePath '\\sccm01\Sources\Scripts\PrintReset' -CommandLine 'cmd.exe /c reset-queue.cmd' -ProgramRunType OnlyWhenNoUserIsLoggedOn -DistributionPointGroupName 'Branch DPs', 'Datacenter DPs'

What you'll see

Example outputvalues are illustrative
PackageName   : Set Power Plan
PackageId     : ABC00123
Program       : Install
CommandLine   : powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Set-PowerPlan.ps1
SourcePath    : \\sccm01\Sources\Scripts\PowerPlan
DistributedTo : All DPs

How it works

  1. Check the content first. Before touching ConfigMgr at all, it makes sure the source folder exists and takes a quick look for the script or installer your command line points at. A typo here is much cheaper to catch now than after a deployment fails on 300 machines.
  2. Connect to the site. It loads the ConfigurationManager module from wherever the console is installed ($env:SMS_ADMIN_UI_PATH) and maps the site drive if it isn't mapped yet. Push-Location and Pop-Location put you back where you started, even if something fails.
  3. Refuse duplicates. ConfigMgr will happily let you create two packages with the same name, and then you get to guess which one is deployed. The script checks with Get-CMPackage -Fast and stops if the name's taken.
  4. Create the package and program. New-CMPackage builds the package, and New-CMProgram adds a standard program that runs with administrative rights.
  5. Distribute, one group at a time. Each DP group gets its own try, so a typo in one group name doesn't cancel the others. You get back an object showing what was created and where it went.

Take it further

  • Build from a CSV. Keep a CSV of name, path and command line, then Import-Csv .\packages.csv | ForEach-Object { .\New-CMScriptPackage.ps1 -SiteCode ABC -PackageName $_.Name -SourcePath $_.Path -CommandLine $_.CommandLine } builds a whole batch.
  • Put packages in a folder. Pipe the new package to Move-CMObject -FolderPath 'ABC:\Package\Scripts' so the console stays tidy.
  • Consider an application instead. If you'll need detection, supersedence or a clean uninstall, an application is worth the extra setup. Packages are best for things that just need to run.

Things that'll trip you up

  • The site server reads the content, not you. The script checks the path from your session, but the site server's computer account is what actually copies it. If it can read the share and the site server can't, distribution fails later with an access denied in distmgr.log.
  • 32-bit PowerShell on 64-bit Windows. Packages run in a 32-bit context by default, so powershell.exe is the SysWOW64 one and registry writes to HKLM\SOFTWARE land under WOW6432Node. If that matters, use %WINDIR%\sysnative\WindowsPowerShell\v1.0\powershell.exe in the command line.
  • Changed the content later? Editing files on the share doesn't update the DPs. Run Update-CMDistributionPoint -PackageId for that package, or right-click it and pick Update Distribution Points.
  • This makes the package, not the deployment. That's on purpose. Deploying to a collection is a decision worth making on purpose, so the script stops short of it. New-CMPackageDeployment is the next step when you're ready.