Inspecting Packages

The macOS installation process installs a pkg file with root credentials. Because of this high level of privileges, it is essential for a Mac system administrator or security expert to be able to inspect the files and scripts.

macOS comes with several tools to work with package files. Most of them are command line tools. pkgutil lets you examine a pkg file and its contents before the actual installation. It also lets you inspect which packages and files have already been installed on a given system.

Installed Packages

You can use the pkgutil command to list packages that have been installed on the system.

$ pkgutil --pkgs

This will list all packages that have been installed on the system. On a freshly installed macOS 15.5 system the list is very short:

com.apple.files.data-template

But depending on the version of macOS and how long the system has been running the list may have hundreds of entries. You can use grep to filter the output, but pkgutil has its own filter option: (I ran this on a system with a few more things installed.)

> pkgutil --pkgs='com.scriptingosx.*'
com.scriptingosx.Installomator
com.scriptingosx.swift-prefs-tools
com.scriptingosx.utiluti
com.scriptingosx.desktoppr

Note that you need to quote the search term otherwise the shell will attempt to expand the wildcard.

Information for an Installed Package

pkgutil --pkgs lists the identifiers of the packages. Identifiers are chosen by developer, but should generally follow the “reverse DNS notation” scheme.

When properly used, identifiers allow the installer process to distinguish between new installations, upgrades and packages that have already been installed. There is another piece of information necessary to determine this and that is the version of a package. To get the version and other information on a specific package run

> pkgutil --info com.scriptingosx.desktoppr 
package-id: com.scriptingosx.desktoppr
version: 0.5-218
volume: /
location: 
install-time: 1720421876 

The install time is logged in epoch time (seconds since January 1, 1970). To convert it into something readable by humans you can use the date command:

> date -r 1720421876
Mon Jul  8 08:57:56 CEST 2024

Note: in earlier versions of Mac OS X the information on installed packages were stored in individual files called receipts. While the information is now stored in a database, the data is still referred to as a receipt.

Listing the Files a Package Installed

The --files option lists all the files that were installed by a package. The file paths are given relative to the packages install-location. Usually, but not always, the install location is the root of the file system/.

> pkgutil --files com.scriptingosx.desktoppr
usr
usr/local
usr/local/bin
usr/local/bin/desktoppr

The --file-info option does the reverse and looks up which package installed a specific file. If a file was placed there by multiple packages with different package identifiers, you will get a list.

> pkgutil --file-info /usr/local/bin/desktoppr
volume: /
path: /usr/local/bin/desktoppr

pkgid: com.scriptingosx.desktoppr
pkg-version: 0.5-218
install-time: 1720421876
uid: 0
gid: 0
mode: 100755

The installer receipt remembers the file’s owner (uid, 0 is root) and group (gid, 0 is wheel) and the permission mode that was stored in the package. Along with the actual files, the installer package also contains the owner, group, and mode (access privileges) for each file.

The metadata in the receipt may not match the file’s metadata on the disk. This indicates that the file was changed since installation. But it is difficult, if not impossible to know whether the change was intentional, accidental or even malicious. You will have to use your good judgement.

Unfortunately, some developers do not know or understand that installation packages also set the metadata for payload files and you often see changes to the owner, group and file mode applied in a postinstall script. When evaluating whether an installed file has been tampered with after installation, it is necessary to check postinstall scripts for such actions.

When a file was not installed by a package installer the --file-info option will return the path and volume, but no package information:

> pkgutil --file-info /Applications/Notes.app       
volume: /
path: /Applications/Notes.app

This is also the case for files that were copied, moved or created by a package’s preinstall or postinstall script. You only get the package data for files that were placed from a package’s payload.

Forgetting an installed package

The installation system on macOS uses the package identifier and version in the receipt to determine if an installation is new to the system, a different or new version of an already installed package, or a re-installation of a package of the same version. The behavior of the installation may change between theses scenarios.

You may notice that when you delete files or apps that were installed from a package and then re-install the same version of the package, that the files may not re-appear on the system. This happens often when you are testing an installation workflow over and over again on the same system.

You can use pkgutil --forget to remove the receipt of a package from the system. The --forget option will not delete any files that were installed on the system. All it removes is the installation receipt. If you then install the same package again, the system will consider it a fresh, new installation and the payload should be installed correctly.

Uninstalling

The macOS installation system does not have an option to uninstall or remove files and apps that were installed with an installation package. You can get a list of files that were installed from the package payload or the receipt. This will be a good starting point, but an app or tool might also create daemons, agents, preferences, configuration files, and other resources in various places across the file system. All these files weren’t part of the package payload and wouldn’t be tracked in the receipt. You will have to inspect all of these and judge whether you need to remove them, as well. Daemon and agents will need to be properly unloaded and quit before deleting their files.

Once you have built a script that performs the un-installation to your satisfaction, you should also run pkgutil --forget and remove the record of the package being installed to ensure a future re-installation will run smoothly.

Inspecting Package Payload Files

Sometimes you want to see what a package file will do without actually installing it. pkgutil has some options for that, too.

Our example file will be the installer I provide for one of my projects: desktoppr. Desktoppr is a command line tool to set the desktop picture or wallpaper on macOS.

You don’t have to actually install desktoppr to inspect the pkg. Though if you want to, you can install the pkg and use pkgutil to determine what it installed and then delete that single file later.

You can download the latest package installer file for desktoppr from the ‘Releases’ section on the GitHub repository. Note that, like many other projects, desktoppr has a pkg and a zip download. For this, we are only interesting in the pkg file.

The file command reports a pkg file is a xar archive:

$ file ~/Downloads/desktoppr-0.5-218.pkg
/Users/armin/Downloads/desktoppr-0.5-218.pkg: xar archive compressed TOC: 4389, SHA-1 checksum

(The exact output of this command may vary depending on your version of macOS and the version of the pkg.)

Packages are compressed into a single archive file. This ‘flat’ package format was introduced in Mac OS X Leopard 10.5 in 2007, replacing and deprecating the previous ‘bundle-style’ packages. Bundle-style packages were finally made defunct in macOS Sequoia 15.0 in 2024. Unless you have to support legacy Macs you should only encounter flat packages.

To expand the package, we can use pkgutil --expand

> pkgutil --expand desktoppr-0.5-218.pkg Desktoppr
> ls Desktoppr
Distribution  desktoppr.pkg

This will create a folder named Desktoppr with the expanded contents of the package file.

Inside this folder, you will see a file named Distribution. Open this file with a text editor. (open -e Desktoppr/Distribution will open the file in TextEdit if you don’t have another editor available.)

This XML file contains the metadata for the installer process. The most interesting elements are pkg-ref, which has the version and the identifier for the package and the components. It also shows the options or components that are available from the user in the Installer application.

There is a sub-folder called desktoppr.pkg inside the expanded folder. The pkgutil --expand command has already expanded this component, so we don’t need to expand it again.

> ls Desktoppr/desktoppr.pkg/
Bom         PackageInfo Payload

Note: When you are inspecting the expanded file structure in Finder, it will show this subdirectory with the package icon. The folder name ends with .pkg which Finder erroneously interprets as a file extension for a bundle-style installation package. If you want to see the contents of this folder in Finder, choose ‘Show Package Contents’ from the context menu.

Inside the sub-directory or component, you will find three more files.

PackageInfo is another XML file with metadata on the component. The most relevant information in here is right in the first pkg-info tag, which has attributes for identifier, version and install-location.

The Payload file is another archive with the actual files inside it. If you wanted to extract the files manually you can do so with:

> tar xvf Desktoppr/desktoppr.pkg/Payload
x .
x ./usr
x ./usr/local
x ./usr/local/bin
x ./usr/local/bin/desktoppr

The folder structure of the payload is relative to the package’s install-location.

Bill of Materials

The last file is called Bom which is short for ‘Bill of material’. It contains an entry for each file in the Payload with additional metadata: owner, group, and file mode (access privileges). It is stored in a binary format, so it cannot be read with a text editor, but you can read the content with the lsbom command.

> lsbom Desktoppr/desktoppr.pkg/Bom
.    40755   0/0
./usr    40755   0/0
./usr/local    40755   0/0
./usr/local/bin    40755   0/0
./usr/local/bin/desktoppr    100755  0/0 271792  550451430

This will output one line per item in the package. The entries or columns per line are: path, file mode, owner id/group id, file size and a CRC 32-bit checksum (only for files).

There are many options to control the output of the lsbom command. You can find them all in its man page.

Since the bill of material (Bom) is very interesting pkgutil provides a shortcut to get it without having to expand the entire pkg file.

> pkgutil --bom desktoppr-0.5-218.pkg
/tmp/desktoppr-0.5-218.pkg.boms.vVNvMz/desktoppr.pkg/Bom

This command will extract the Bom into a temporary file and output the path. You will use this most commonly together with lsbom.

pkgutil also has a --payload-files option:

pkgutil --payload-files desktoppr-0.5-218.pkg 
.
./usr
./usr/local
./usr/local/bin
./usr/local/bin/desktoppr

This output shows only the file path. If you require more information, use the --bom option to export the Bom file and use lsbom.

More Complex Packages

The desktoppr installation package is a very simple package. It installs a single binary file.

For a slightly more complex package, you can download the installer pkg for Setup Manager. Setup Manager is an enrollment tool that works with Jamf Pro and Jamf Connect.

Again, you do not have to actually run the installer to inspect. In this case, the tool will only work on a Mac managed with a Jamf management server at enrollment. Nevertheless inspecting this package will be instructive.

First, use pkgutil to list the payload files.

> pkgutil --payload-files Setup\ Manager-1.3.1-610.pkg
.
./Library
./Library/LaunchAgents
./Library/LaunchAgents/com.jamf.setupmanager.loginwindow.plist
./Library/LaunchDaemons
./Library/LaunchDaemons/com.jamf.setupmanager.plist
./Library/LaunchDaemons/com.jamf.setupmanager.finished.plist
./Applications
./Applications/Utilities
./Applications/Utilities/Setup Manager.app

There are more files that are listed, but they are all files and folders in the Setup Manager.app bundle. This package installs two LaunchDaemons and a LaunchAgent, as well as the Setup Manager application in /Applications/Utilities

To learn more, expand the package file with pkgutil:

> pkgutil --expand Setup\ Manager-1.3.1-610.pkg SetupManager
> ls SetupManager                     
Distribution      Resources         Setup Manager.pkg
> ls SetupManager/Resources        
License.rtf Readme.rtf

We see a new subfolder named Resources which contains two rich text files. These are shown in the respective panes when the pkg file is opened with the Installer application. You can double-click the Setup Manager pkg to open it in the Installer application and see the two panes. You don’t need to follow through with the installation.

When we dig further into the expanded Setup Manager we see another folder we did not have before:

ls SetupManager/Setup\ Manager.pkg/
Bom PackageInfo Payload Scripts
ls SetupManager/Setup\ Manager.pkg/Scripts
postinstall preinstall

The Scripts folder in the component contains two scripts: preinstall and postinstall. The installation process will run these scripts before and after the payload files are installed on the system.

When you open the script files in a text editor, you can see that these unload and load the LaunchAgents and Daemons in the payload.

You can use pkgutil and lsbom to inspect all kinds of packages. If you want to practice, the Microsoft installers are a very good exercise.

Component Packages

There is a simpler type of packages. As an example, download the installer pkg for an early version of desktoppr.

When you expand this pkg file with pkgutil, you will see no Distribution XML file or sub-component folders.

$ pkgutil --expand desktoppr-0.3.pkg Desktoppr0.3
ls Desktoppr0.3/
Bom PackageInfo Payload

Instead you see the three files we saw earlier in the component subfolder of the main pkg: Bom, PackageInfo, and Payload. Nevertheless, if you were to install this package, it would work just fine and install its payload.

This is a component package. Generally, component packages are built as an intermediate step to assemble the distribution package format we saw earlier. Nevertheless, component package files will work fine on their own, as well.

Distribution Packages, Product Archives, and Component Packages

Most of the pkg files you will encounter are distribution packages. Distribution packages do not have a payload or installation scripts of their own. Distribution packages contain one or more components. Each component will have a payload and (possibly) installation scripts.

Distribution packages are wrappers for their components and can have some extra data, such as the License and ReadMe file we saw earlier.

Apple’s developer documentation often refers to “product archives.” Product Archives are a different name for distribution packages with a specific set of metadata. Most relevantly, product archives have an identifier and version set.

Distribution packages and product archives allow the developer to customize the interactive installation process in the Installer application. Product archives are also a requirement for publishing in the Mac App Store. For these reasons, product archives are the recommended choice for developers to distribute their software.

Component packages already provide the most relevant feature for package installers: they install files. They are quite simple to create, which makes them popular with Mac system administrators who often need to build custom installers that are installed silently from a management system. There are, however, some situations where distribution packages are required with management systems, too.

Suspicious Package

Understanding the command line tools and workflows to expand and inspect pkg files is a good exercise and an important foundation to building packages. Nevertheless, it can be tedious when all you want is to just to see the files inside or some metadata for the package.

The application ‘Suspicious Package‘ provides a powerful and useful graphical interface for inspecting installation packages and their payloads. It gives an overview of package’s metadata, including signature and notarization status. It will show a detailed graphical view of the payload, the metadata files and installations scripts. When necessary, you can preview or extract individual files for further analysis

There will still be situations where you will need pkgutil, but Suspicious Package is an indispensable tool for any Mac Admin and Mac security professional. You can download Suspicious Package for free from Mothers Ruin Software.

Updates: Setup Manager and utiluti

Setup Manager 1.3

We have released Setup Manager 1.3 today. You can see the release notes and download the pkg installer here.

Most of the changes to Setup Manager in the update do not change the workflow directly. The focus for this update was to improve logging and information provided for trouble-shooting.

With the 1.3 update, Setup Manager provides richer logging information. You will find some entries in the Setup Manager log that were not initiated by the Setup Manager workflow, but are still very relevant to troubleshooting the enrollment workflow. You can see all installation packages that are installed during the enrollment, as well as network changes. This allows an admin to see when managed App Store installations or other installations initiated from the MDM or Jamf App Installers are happening in the enrollment workflow.

These can be very helpful to determine what might be delaying or interrupting certain other installations.

When we started building the “enrollment tool we wanted to use ourselves” more than two years ago, we chose to build a full application, rather than a script-based solution which remote controls some interface. One of the immediate benefits is that we could make the user interface richer and more specialized. Localizing the app into different languages was easier, too. Setup Manager adds Polish localization, bringing the total number of languages to ten!

(We use the help of volunteers from the community to localize to other languages, if you want to help localize Setup Manager into your language, please contact me.)

There was another goal, which took a bit longer to realize.

Swift apps allow us to dive deeper into the capabilities and information available in the operating system. A full blown app is also more capable at analyzing and displaying multiple sources of information at the same time. For example, Setup Manager will display a big warning when the battery level drops below a critical threshold.

These kinds of workflows and user interfaces would be nearly impossible or, at the very least, extremely complex to build and maintain with shell scripts. In this case, Setup Manager is monitoring and parsing other log files and summarizing them down to some important events in the background, while it is working through its main purpose of running through the action list from the profile.

This feature will not be seen by most users or even techs who are sitting in front of the Mac, waiting for the base installation to finish. But when you are trouble shooting problems during your enrollment workflow, these extra log entries can be very insightful. Even during testing, it unveiled some surprises in our testing environments.

We hope you like the new features. But, we are also not done yet and have plenty more ideas planned for Setup Manager!

utiluti 1.2

Since we are talking updates, I have also released an update to my CLI tool to set default apps for urls and file types (uniform type identifiers/UTI). utiluti 1.2 adds a manage verb which can read a list of default app assignments from plist files or a configuration profile. You can see the documentation for the new manage verb here and download the latest pkg installer here.

This allows you to define lists of default apps and push them with your device management system. Then you can run utiluti from a script in the same management system. This should greatly simplify managing default apps.

Note, that while you can set the default browser with utiluti, whether you are using the manage option or not, the system will prompt the user to confirm the new default browser. For this use case, you will want to put the utiluti command in a context where the user is prepared and ready for that extra dialog (such as a Self Service app). There are other tools, such as Graham Gilbert’s make-default CLI tool, which bypass the system dialog. In my experience, tools like this work well in fairly clean setup and require a logout or reboot after the change. This might fit your workflow, but you need to test.

I hope utiluti will find a place in your MacAdmin’s toolbox!

Installomator v10.8

Further chipping away at the backlog of new and updated with merged 200 PRs merged or closed.

The new PR templates and automations are proving to be a big help! Many thanks Bart for working on these and all the maintainers for staying on top of most things.

This release brings Installomator to 1025 (!) labels!

Many thanks to all the contributors, this tool wouldn’t exist without you!

You can find the detailed release notes and the pkg on the repo!

New tool: utiluti sets default apps

A while back I wrote a post on the Jamf Tech Thoughts blog about managing the default browser on macOS. In that post I introduced a script using JXA to set the default application for a given url scheme. (like http, mailto, ssh etc.) The beauty of using JXA/osascript is that it doesn’t require the installation of an extra tool.

However, there was a follow-up comment asking about default apps for file types, i.e. which app will open PDF files or files with the .sh file extension. Unfortunately, Apple has not bridged those AppKit APIs to AppleScript/JXA, which means it is not possible to use them in a script without dependencies.

Back then, I started working on a command line tool which uses those APIs. I didn’t really plan to publish it, since there were established tools, like duti, cdef and SwiftDefaultApp that provided the functionality. It was a chance to experiment and learn more about Swift Argument Parser. Then life and work happened and other projects required more attention.

A recent discussion on the Mac Admins Slack reminded me of this. Also, none of the above mentioned tools have been updated in the past years. As far as I can tell, none of them have been compiled for the Apple silicon platform. They don’t provide installation pkgs either, which complicates their distribution in a managed deployment.

So, I dusted off the project, cleaned it up a bit, and added a ReadMe file and a signed and notarized installation pkg. The tool is called utiluti (I am a bit proud of that name).

You can use utiluti to set the default app for an url scheme:

$ utiluti url set mailto com.microsoft.Outlook
set com.microsoft.Outlook for mailto

or to set the default app to open a uniform type identifier (UTI):

$ utiluti type set public.plain-text com.barebones.bbedit
set com.barebones.bbedit for public.plain-text

There are bunch of other options, you can read the details in the ReadMe or in the command line with utiluti help.

The functionality is quite basic, but please provide feedback if there are features you’d like to have added.

Installomator v10.7

Chipping away at the backlog of PRs and issue, we have released a new version of Installomator today.

Main focus was on releasing a whole bunch of new and updated labels. But the maintainer team has also started work on implementing to templates for issues and PRs and some automation for testing. This should help a lot with the effort to keep up with new issues and PRs going forward.

Many thanks to all the contributors and maintainers for the hard work that went into this!

You can find [the detailed release notes and the downloads on the repo!](https://github.com/Installomator/Installomator/releases/tag/v10.7)

Run a script when Setup Manager is finished

You may have heard, we built an enrollment tool called Setup Manager for Jamf Pro and Jamf School.

As with any new tool, there are some things that it doesn’t do yet and there are some conflicts with other software that we hadn’t anticipated.

We are working on these things, but it takes time. Until then, I thought it might be useful if there were a way to trigger you own scripts when Setup Manager is finished with its workflow. This wasn’t too hard to put together and I can already see a few useful applications.

How it works

You can use LaunchDaemons to run your own scripts and tools in the background. There are many different triggers you can use to control when your LaunchDaemon launches. One option is a WatchPath which triggers when a file at a certain path is created, deleted or modified.

Setup Manager creates a flag file at /private/var/db/.JamfSetupEnrollmentDone when it completes successfully. We can use this file as a watch path to launch our own script: setupManagerFinished.sh.

In the script, we first set a few useful variables. Since the WatchPath LaunchDaemon triggers on creation, deletion and modification, the script checks first whether the flag file exists, otherwise it just exits without doing anything.

Then the script waits for the jamf binary to not be running anymore. With Jamf Pro, when Setup Manager finishes up, it creates the flag file and then runs its final recon/inventory update. It has to do it in this order, so that an extension attribute that is checking for the flag file gets updated correctly. That means that our script will be triggered before Setup Manager is actually really done. But we can be fairly sure that when the jamf process is done, then Setup Manager has really finished its workflow.

Setup Manager might be still displaying its UI here, but the work is done. (If you want to defer launching until Setup Manager itself quits, you can replace jamf with "Setup Manager" (quotes are important) in line 30.)

Then, we get to the point where we run what we actually want to do. In the sample project, the script runs the jamf binary with a custom trigger. All policies in scope with that custom trigger will be executed. You can even attach a restart action to the last of those policies to force a reboot at this point.

SetupManager and this LaunchDaemon can run with Jamf School, as well. In that case, you wouldn’t run the jamf binary since that is exclusive to Jamf Pro. But you can modify the script to run other commands here to finish the setup.

Chicken, meet egg

You can do additional installations using Installomator, especially for tools that might interfere with Setup Manager. You can send notifications to Slack or Teams with data from the completed Setup Manager workflow.

You can also force a restart here with the shutdown -r +5s command. (The five second delay gives the LaunchDaemon script a chance to clean up and exit.)

Authentication tools, like Jamf Connect or XCreds, need to restart the login window so that their software can load correctly. If you are using the option to run Setup Manager at the login window, this will quit and then restart Setup Manager, too.

Some tools and apps have installation workflows that simply don’t work well with Setup Manager for other reasons.

That means you want to avoid installing these tools with Setup Manager directly, but you can install them after Setup Manager is done.

With this LaunchDaemon, you can run all kinds of additional workflows and install troublesome tools, either with a Jamf Pro policy or Installomator.

Usage

Download the repo and adapt the setupManagerFinished.sh script to your needs. The code you want to adapt is in lines 39 through 47.

The default script triggers a custom policy trigger (setup_manager_finished, defined in line 10)

The buildSetupManagerFinishedPkg.sh script will assemble the LaunchDaemon plist, the script and the installation scripts into a pkg.

You will have to adapt the name of the certificate you use to sign the pkg in line 20. If you do not have a signing certificate, you can set signature="" and the script will build an un-signed pkg. You need a signed installer pkg when you want to add it to the Jamf Pro Prestage or install it with Jamf School. When the pkg is not signed, you can still install it with a Jamf Pro policy with a Setup Manager action. As long as it is installed before Setup Manager finishes, it will trigger when the flag file gets created.

Conclusion

You can find the code on the repository. I am curious what workflows y’all are going to build with this! Let us know in the Mac Admins Slack in the #jamf-setup-manager channel!

Installomator v10.6

It is that time again, we have released a new version of Installomator!

Many thanks to all the maintainers and contributors!

It has been quite a while since we published a release and there was quite a backlog. In some ways, Installomator is a victim of its own success. It is quite easy to create a new label and many contributors are doing that. It is also easy to provide an update to a broken label and while many contributors are doing that, there are fewer of them.

This means that we are accumulating a lot of stale and broken labels. Since there is now way for us to gather information on which labels are actually being used and how much, we end up with lot of turnover.

The team of maintainers has some ideas to tackle some of these problems with automation, but human curation will have to remain a step all along the way.

You can get the latest version of Installomator on the repo or download the installer pkg from here.

Swift Command Line Tools and Argument Parser — Part 1

When building tools in Swift, I usually start with a command line tool. This allows me to ignore the complexity of creating a user interface while figuring out the underlying APIs and data models.

Technically, command line tools have a user interface, as well. They print output to pipes, standard out or standard error for data, progress, status or other information. They should provide information using the exit status. They take input from standard in, pipes, environment variables, or from command line arguments.

While there are many subtleties to consider with all of these, these “interfaces” are still less complex to handle than a full user interface built with AppKit, UIKit or SwiftUI.

Swift provides the functionality to deal with files, outputs, pipes with APIs. This post will not cover those. This post will focus on getting arguments from the command line.

Note: this tutorial was written using Swift 5.10, Xcode 15.4, swift-argument-parser 1.5 on macOS 14.6.1. The details and user interfaces may change with different versions, but the fundamentals should remain.

CommandLine.arguments

The built-in way to get the arguments for your process is with CommandLine.arguments which returns an array of String objects. CommandLine.arguments is quite basic, but can be suitable for simple use cases.

Create a project directory named CLArgs and use swift package init to create a swift package to build an executable:

> mkdir CLArgs
> cd CLArgs
> swift package init --type executable

This will create a skeleton project, you will find a basic “Hello, world” code in Sources/main.swift. Replace the print statement there with:

import Foundation
let arguments = CommandLine.arguments.dropFirst()
guard let name = arguments.first
else {
  print("command requires an argument!")
  exit(1)
}
print("Hello, \(name)")

Note: You can use your favorite text editor or IDE to edit SPM projects. You can also use Xcode. When you run xed . in the Swift package directory, Xcode will open the Swift package in a project view. You can edit, build and run the package in Xcode or use Xcode for editing and build and run it from the command line.

In terminal, build and run the project with

> swift run CLArgs Armin

This tells the swift to build the CLArgs target defined in Package.swift and run it with the argument Armin. You should see this output:

> swift run CLArgs Armin
Building for debugging...
[1/1] Write swift-version-39B54973F684ADAB.txt
Build of product 'CLArgs' complete! (0.11s)
Hello, Armin

Let’s look at the code in detail.

let arguments = CommandLine.arguments.dropFirst()

CommandLine.arguments returns an array of strings. By convention, the first argument (arguments[0]) contains the path to the executable. In most situations, you will not be interested in this first argument. One straightforward way to deal with this is to ‘drop’ the first element of the array right away.

guard let name = arguments.first
else {
  print("command requires an argument!")
  exit(1)
}

We get the first element of the arguments array. Additional arguments are simply ignored. When no arguments are provided, this will return nil and guard statement will trigger, where we print an error message and exit the code with a non-zero value, signaling a failure.

print("Hello, \(name)")

The actual point of this sample code: print a greeting with the name.

In this simplest of examples, we spend a majority of the code on preparing the arguments and verifying that they meet our requirements.

CommandLine.arguments will serve you well for simple needs and quick command line tools. However, you will quickly notice that a robust command line tool needs to verify the existence of certain arguments, whether the value matches certain criteria, and print error messages and usage directions when the arguments don’t match the expectations. Many command line tools also have flags and options with short and long forms that need to be processed.

This turns into a lot of code very quickly.

Swift Argument Parser

Enter Swift Argument Parser. A package that provides “straightforward, type-safe argument parsing for Swift.”

You could modify the Package.swift file in our CLArgs project to import Swift Argument Parser but there is an even easier way to start. Back out of the CLArgs project directory and create a new one:

> cd ..
> mkdir SwiftArg
> cd SwiftArg
> swift package init --type tool

When you inspect the Package.swift file in this new project, you will see that it is already linked to the Swift Argument Parser package. Sources/SwiftArgs.swift contains another “Hello, world” template code, but using Swift Argument Parser.

import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
  mutating func run() throws {
    print("Hello, world!")
  }
}

The struct here implements the ParsableCommand protocol which allows us to use all the nice functionality from the ArgumentParser library. It is also marked with the @main tag, which tells the compiler to run the main() function in this when the binary is launched. The main() function is implemented by ParsableCommand which, well, parses the arguments and then launches the run() function.

Swift Package Manager vs Xcode projects

You can open and edit Swift Package Manager projects in Xcode with the xed . command. Recent Xcode versions know how to work with SPM projects without needing to create an Xcode project file. Xcode will use the configurations in the Package.swift file. This is useful when you like to work in Xcode, but want the project to remain transferable to other editors or IDEs.

There is a weird quirk. When you build and/or run the project from within Xcode it will use the default Xcode build directory (default is ~/Library/Developer/Xcode/DerivedData/). This is different from the location that the swift build or swift run commands in Terminal use (.build in the package directory). This can lead to longer build times and confusion.

You can also use swift-argument-parser and other packages within Xcode projects. This can be necessary if you are building the command line as a target within a larger Xcode project. Maybe you want to use some of Xcode’s advanced features for managing projects, like build phases and Archives. Or maybe you just prefer working in Xcode.

To create a command line with ArgumentParser in Xcode, create a new Project and select the ‘Command Line Tool’ template for macOS. Once the new project is created, select ‘Add Package Dependencies…’ from the File menu. Locate ‘swift-argument-parser’ in the ‘Apple Swift Packages’ collection or just enter the URL in the search field and click ‘Add Package…’ (twice)

Then, you have to delete the main.swift file from the template and create a new SwiftArgs.swift with this code:

import Foundation
import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
  mutating func run() throws {
    print("Hello, world!")
  }
}

This is the same as the template code created with the swift package init --type tool from above.

When testing and running the command line tool in Xcode will will want to pass arguments into the binary. You can do so by editing the scheme. Choose Product > Scheme > Edit Scheme… from the menu or click on the target icon (the one with the command line icon in the center of the menu bar) and select Edit Scheme… Make sure you are on the ‘Run’ section in that dialog and select the ‘Arguments’ tab. Here you can add, remove, enable or disable the arguments that Xcode passes into your tool when you run it from Xcode.

Continually changing the arguments in the scheme editor can be tedious. You can also use ‘Show Build Folder in Finder’ from the ‘Product’ menu, open the Products/Debug folder in Terminal by dragging that folder on to the Terminal icon in the Dock and run the built command from there with ./SwiftArgs

Whichever way you prefer to create and work with your project, the rest of this tutorial will work the same way.

Using Swift Argument Parser

Right now, we are just running print("Hello, world!"), which is quite underwhelming. Let’s step this up just a little bit:

@main
struct SwiftArgs: ParsableCommand
  @Argument var name: String
  func run() {
    print("Hello, \(name)")
  }
}

First we create a property called name of type Stringwith the @Argument property wrapper. This tells the ArgumentParser library, that we want this variable filled with an argument from the command line. When the run() function is called, we can “just use” the name property, like any other Swift property.

When you run this, something interesting happens: we get an error!

> swift run SwiftArgs
Error: Missing expected argument '<name>'
USAGE: swift-args <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

When you check the exit code of the command with echo $? you see it return an error code of 64. This means it was missing arguments or got malformed arguments. As should be good practice for command line tools, our tool did print a help message, describing what it was expecting. Here we see that our SwiftArgs command expects a single argument giving a name.

Run the command again, but with an argument:

> swift run SwiftArgs Armin
Hello, Armin

Now everything works as expected. When our tool launches, ArgumentParser grabs the argument, places it in our name property and executes the run() function in our struct that implements ParsableCommand. Since ArgumentParser errors out with the help message, when an argument is missing or too many arguments are present, we can be certain that the name variable is populated when our code runs.

Command Configuration

There is a small detail that is bugging me, though. The help message generated by ArgumentParser deduced that the name of binary should be swift-args instead of SwiftArgs, but the binary name is SwiftArgs, which is the name of the directory we initialized the project in. This is because of different naming standards for Swift types and command line tools. You can change the name of the executable created in the Package.swift file in line 15 under .executableTarget.

We could change the name to something completely different here, say apdemo for ‘Argument Parser Demo. When you apply that change inPackage.swift` it changes the name of the binary, but the auto-generated help message does not pick that up. It still use the auto-generated name.

> swift run apdemo --help
USAGE: swift-args <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

(Isn’t it neat that ArgumentParser automatically implements --help and -h flags?)

We could change the name of our struct, which will work in simple situations. But you will have a situation where the struct name will not match what you want for the executable name. There is a way to tell ArgumentParser exactly what we want, though.

Insert this code below the struct SwiftArgs line and above the @Argument:

  static let configuration = CommandConfiguration(
    commandName: "apdemo"
  )

When you now look at the generated help again, the command name matches:

OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

There is more information we can provide in the command configuration. Extend the CommandConfiguration initializer like this:

  static let configuration = CommandConfiguration(
    commandName: "apdemo",
    abstract: "apdemo - swift-argument-parser tutorial tool",
    version: "0.1"
  )

and run the command to get the help message again.

OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
  <name>
OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

The abstract appears as an ‘overview’ above the help message and we now see a new option --version. When you run the tool with that option, you will not be surprised to see the 0.1 provided in the configuration, but it is useful nonetheless.

There are more fields you can provide in the CommandConfiguration: discussion allows you to provide a long form description of the command. usage allows you to override the auto-generated usage text. There are some more that we will explore later.

More Arguments

You can add more @Arguments and they will be filled in order from the arguments provided at the command line. Add another property with the @Argument wrapper:

  @Argument var name: String
  @Argument var age: Int
  func run() {
    print("Hello, \(name)!")
    print("You are \(age) years old.")
  }

When you run the tool without any arguments, you can inspect the updated help message. The usage and arguments area now shows both expected arguments. When you run the tool with a single argument, you get an abbreviated help, showing only the missing argument. When you provide a name and a number as command line arguments, everything works as expected.

But what if you provide two strings?

> swift run apdemo Armin lalala            
Error: The value 'lalala' is invalid for '<age>'
Help:  <age>  
Usage: apdemo <name> <age>
  See 'apdemo --help' for more information.

We declared the age property as an Int, so ArgumentParser expects an integer number for the second argument. When the second argument cannot be parsed into an integer, it shows the error.

Change the type of the age property to a double and run it again with a decimal for the age.

Some Help, please?

name and age might be enough to tell a user of your command line tool what to enter. But I think we should provide a bit more explanation. You can attach a help message to the argument:

  @Argument(help: "a name")
  var name: String
  @Argument(help: "age (integer number)")
  var age: Int

I have broken the property declarations into two lines each for clarity and changed the age back to an Int for simplicity. The help messages will appear next to the argument names in the long and short help messages.

Off to a good start

We have just started to scratch the surface of what swift-argument-parser can do for us. In the next part, we will cover options and flags.

Building a LaunchD Installer pkg for desktoppr (and other tools)

In my last post introducing desktoppr 0.5, I said that a Mac admin could build a customized pkg with a customized LaunchAgent property list, that would run desktoppr manage at login and every few hours in the background to re-set the wallpaper when the configuration profile is updated.

I admit I glossed over the process of building a package that installs and launches a LaunchAgent (or LaunchDaemon). Since that is not a trivial configuration, I will describe the details in this post.

LaunchAgent? LaunchDaemon? LaunchD?

On macOS, launchd is the system-wide process that controls and launches every other process running on the system. When you look at the hierarchical process list in Activity Monitor, you will always see launchd running with the process ID 1 as the single child process of kernel_task and all other processes are a child of launchd.

More practically, users and admins can use launchd to control processes that need to be available at all times or be launched in certain intervals or at certain events.

launchd has two types of processes: LaunchDaemons and LaunchAgents. Since the terms daemons (or demons) and agents do not have clear definitions outside of launchd, a bit of explanation is required.

LaunchDaemons

LaunchDaemons are processes that run with system privileges (“as root”). Processes that belong to your management system and security suites may need to run periodically or on certain events in the background and require root level access to the system to do their jobs. Other processes or services need to listen for incoming network traffic on privileged ports.

Note that background process are often called ‘agents’ regardless of whether they run with root privileges or as a user. The launchd terminology is more precise here.

Apple has done a lot of work over the past few year to provide Endpoint Security or Network Extension frameworks that mitigate the need for third party root daemons, but there are still relevant use cases, especially when you are a managing Macs.

A LaunchDaemon is configured by providing a property list file in /Library/LaunchDaemons. This configuration plist will be automatically loaded at reboot, or can be loaded using the launchctl command.

LaunchAgents

LaunchAgents are processes that run with user privileges. Generally, if something needs to read or write to anything in the user home folder, it should be a LaunchAgent. Over the recent years, Apple has put restrictions on what data process can access in user space, which has made LaunchAgents more of a pain to manage, but they still have their use cases.

A LaunchAgent is configured by providing a property list file in /Library/LaunchAgents on the system level or in ~/Library/LaunchAgents in a user’s home folder. Configuration property lists that are in a user home folder, will only run for that user, while LaunchAgents that are configured in the system wide /Library/LaunchAgents will be loaded for all users on the system.

A LaunchAgent configuration plist will be automatically loaded at login, or can be loaded using the launchctl command.

Since desktoppr affects a user setting (the desktop/wallpaper), it has to run as the user, so we need to build and install a LaunchAgent. Creating and installing a LaunchDaemon is pretty much the same process, though.

Ingredients

To build an installation package to install and run a LaunchAgent or LaunchDaemon, you need three ingredients:

  • the binary or script which will be launched
  • a launchd configuration property list
  • installation scripts to unload and load the configuration

For desktoppr, we already have the binary (downloadable as a zip from the project release page) and a sample configuration property list file.

The sample launchd configuration looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.scriptingosx.desktopprmanage</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/desktoppr</string>
        <string>manage</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StartInterval</key>
    <integer>10800</integer>
</dict>
</plist>

The label is required and is used to uniquely identify the LaunchAgent or Daemon with launchd and the launchctl command. Reverse domain notification is common. The filename of the configuration property list should match the label.

The ProgramArguments key provides an array with the command and arguments that will be launched. The first item in the array should provide the full path to the binary or script. Then you provide arguments, one string item per argument. Since the individual arguments are separated by the string tags, you do not need to escape spaces or other special characters in the arguments.

It is worth noting that launchd will launch the binary or script directly, so syntax and substitutions that are done in shells will not work. This includes variable and command substitution like $HOME, $USER, or $(date) as well as pipes and output redirection. You get a single command with a list of static arguments. (You can use the StandardOutPath and StandardErrorPath keys in the launchd config plist to redirect output.)

For the desktoppr LaunchAgent, that suits us just fine, as we are only passing the manage argument which tells desktoppr to get the details from a configuration profile (sample here).

The next key, RunAtLoad tells launchd, to run the binary with the arguments immediately when the configuration file is loaded. Since configuration files in /Library/LaunchAgents and ~/Library/LaunchAgents are automatically loaded when a user logs in, this generally means desktoppr manage will be run when a user logs in, which suits us well.

The last key StartInterval tells launchd to re-launch the process after a certain time (given in seconds, the 10800 seconds in our sample file translate to three hours). Should the system be sleeping at the time, it will not run the LaunchAgent at that time, or when the system wakes up, but wait until the next interval period comes around.

There are other keys that control when the process gets launched, such as StartCalendarInterval or WatchPath. You can read details in the man page for launchd.plist or on the excellent launchd.info page. There are also apps, like Peter Borg’s Lingon that provide a user interface for creating these plist files.

Move or copy the plist file into the correct folder. The plist files in /Library/LaunchDaemons and /Library/LaunchAgents must be owned by root. They must not be writable by group or other. The file mode 644 (rw-r--r--) is recommended. Similarly, the binary or script needs to be owned by root and cannot be writable by group or other. The file mode 755 (rwxr-xr-x) is recommended. Wrong file privileges will result in a Load/Bootstrap failed: 5: Input/output error.

Make sure that the binary or script does not have a quarantine flag attached. You can check with ls -al@ or xattr -p com.apple.quarantine /path/to/file and remove a quarantine flag with xattr -d com.apple.quarantine /path/to/file. When the binary or script has the quarantine flag, the configuration file will load fine but the actual execution will fail quietly.

Loading the LaunchAgent or LaunchDaemon

The easiest way to load a LaunchDaemon is to restart the Mac. The easiest way to load a LaunchAgent is to log out and login. This is usually not practical.

The launchctl command manages LaunchDaemons and LaunchAgents and we can use this to load LaunchAgents and LaunchDaemons on demand.

To load our desktoppr agent, use

> launchctl load /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

People are already readying their pitchforks here. “Hold on,” they say. “Apple has marked load and unload as ‘legacy.’ You shouldn’t use them!”

While it is true that load and unload (and a bunch of other commands) are labeled as ‘legacy’ in the launchctl man page, this does not mean they are deprecated and should be avoided. The difference between the legacy and the ‘new’ commands is that the legacy commands pick up whether a process should be run in user or root context (the ‘target domain’) from the context that launchctl runs in, whereas the new bootstrap and bootout commands need the target domain to be stated explicitly.

This makes the modern commands more precise, but often more wordy to use. You will see that the legacy commands are simpler to use in the interactive terminal, while the ‘modern’ commands are more useful in scripts. The equivalent ‘modern’ command is:

> launchctl bootstrap gui/501 /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

where 501 is the user ID for your user account. This is 501 by default for the first user on on a macOS system, but you should verify what your account’s user ID actually is with id -u.

See what I mean with “more wordy to use”? That doesn’t mean they are bad or should be avoided, but that we can be picky about when to use which format. You could argue that using the modern commands throughout would be more consistent, but you still find the ‘legacy’ command is lots of documentation, so I feel a Mac admin needs to be aware of both.

Note: the ‘modern’ commands were introduced in OS X Yosemite 10.10 in 2014.

To load a LaunchDaemon, you need to run launchctl with root privileges. In the interactive shell, that means with sudo:

> sudo launchctl load /Library/LaunchDaemons/com.example.daemon.plist

or

> sudo launchctl bootstrap system/ /Library/LaunchDaemons/com.example.daemon.plist

To stop a LaunchAgent from being launched going forward, unload the configuration:

> launchctl unload /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

or

> launchctl bootout gui/501/com.scriptingosx.desktopprmanage

The bootout command uses the label instead of the file path.

Putting it all together in an installer

Note: I will be showing how to build and installer package for our LaunchAgent using command line tools. These instructions should have all the information you need, even if you prefer using apps such as Whitebox Packages or Composer.

If you are not comfortable with using Terminal on macOS yet, please consider my book “macOS Terminal and Shell.” If you want to learn more about building installation packages, consider my book “Packaging for Apple Administrators.”

Download the zip file for desktoppr from the releases page. Download the sample launchd property list file and modify the launch criteria (StartInterval or StartCalendarInterval) to your requirements.

Open Terminal, change directory to a location where you want to create the project directory and create a project directory:

> mkdir DesktopprManagePkg
> cd DesktopprManagePkg

Then create a payload and a scripts directory in this project folder:

> mkdir payload
> mkdir scripts

Unzip the desktoppr binary from the downloaded zip archive into the right folder hierarchy in the payload folder:

> mkdir -p payload/usr/local/bin/
> ditto -x -k ~/Downloads/desktoppr-0.5-218.zip payload/usr/local/bin/

Remove the quarantine flag from the expanded desktoppr binary and test it by getting the version and the current wallpaper/desktop picture:

> xattr -d com.apple.quarantine payload/usr/local/bin/desktoppr
> payload/usr/local/bin/desktoppr version
0.5
> payload/usr/local/bin/desktoppr
/System/Library/CoreServices/DefaultDesktop.heic

Create the /Library/LaunchAgents directory in the payload and copy or move the LaunchAgent configuration plist there:

> mkdir -p payload/Library/LaunchAgents/
> cp ~/Downloads/com.scriptingosx.desktopprmanage.plist payload/Library/LaunchAgents/

When the installation package runs, the files in the payload will be moved to the respective locations on the target drive. The pkgbuild tool will set the owner of the files to root when building the package, but we should verify that the file mode is correct:

> stat -f %Sp payload/usr/local/bin/desktoppr
-rwxr-xr-x
> stat -f %Sp payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
-rw-r--r--

If the privileges don’t match the desired mode, set them with chmod

> chmod 755 payload/usr/local/bin/desktoppr
> chmod 644 payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

and run the stat commands from above again to verify.

If you built the installer package now, it will install the files in the right locations and the LaunchAgent will load and run desktoppr manage on next login. This will set the desktop/wallpaper according to the information in the configuration profile. You should install and update the configuration profile using your management system.

Loading the LaunchAgent or LaunchDaemon

However, as I said earlier, requiring or waiting for a logout or reboot is not ideal, we would like to have the LaunchAgent load immediately when the package is installed. We can achieve this by adding a postinstall script to the installation package. This script will be executed after the payload files have been installed.

With your favored text editor, create a file named postinstall (no file extension) with this content in the scripts sub directory.

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"

# do not load when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
    echo "not installing on startup volume, exiting"
    exit 0
fi

# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/

currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')

# don't load at loginwindow 
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
    echo "no user logged in, exiting"
    exit 0
fi

uid=$(id -u "$currentUser")

echo "loading $label"
launchctl bootstrap "gui/$uid" "$launchdPlist"

Set the proper file privileges on the postinstall file with

> chmod 755 scripts/postinstall

First the script sets the PATH. Then some variables for the label and bath to the label. You can change these to adapt the script to other LaunchAgents or LaunchDaemons.

Then the scripts checks the $3 argument. The installer system passes the target volume to the postinstall script in this argument. While it is a not common in deployment workflows any more, you can still install a pkg on a target volume that is not the current boot volume “/“. We remain safe with our script and check for that possibility, and exit without loading the agent.

LaunchAgents have to be loaded or bootstrapped into the current user’s context. The script gets the current user and checks whether the system might be sitting at the login window. We exit without loading if the system is at the login window or if the current user is _mbsetupuser which means the package is being installed while the system is sitting at Setup Assistant during first setup. Now that the LaunchAgent plist is in place, it will be loaded the first time the user logs in.

When there is a user logged in, we bootstrap the LaunchAgent right away. This is where the modern launchctl syntax shows its advantages. With the legacy commands, this would be:

launchctl asuser "$uid" launchctl load "$launchdPlist"

When you load a LaunchDaemon in a postinstall, the script is much simpler, as you don’t need to check for a current user. You can directly load the daemon into the system target domain:

launchctl bootstrap system/ "$launchdPlist"

Unloading before installation

When you are updating existing software with a package, you have to consider the situation where a LaunchAgent or LaunchDaemon is already running. In this case, we should unload before the installation. We can add a preinstall script (also no file extension) to the scripts folder that will be run before the payload is installed. This closely parallels the postinstall script.

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"

# do not unload when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
    echo "not installing on startup volume, exiting"
    exit 0
fi

if ! launchctl list | grep -q "$label"; then
    echo "$label not loaded, exiting"
    exit 0
fi

# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/

currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')

# don't unload at loginwindow or Setup Assistant
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
    echo "no user logged in, exiting"
    exit 0
fi

uid=$(id -u "$currentUser")

echo "loading $label"
launchctl bootout "gui/$uid" "$launchdPlist"

Again, set the proper file privileges on the preinstall file with

> chmod 755 scripts/preinstall

There is one extra step compared to the postinstall script. The preinstall uses launchctl list to check if the launchd configuration is loaded, before attempting to unload it.

Building the Package Installer

Now we get to put everything together. You can use pkgbuild to build an installer package:

> pkgbuild --root payload/ --scripts scripts/ --version 1 --identifier com.scriptingosx.desktopprmanage --install-location / DesktopprManage-1.pkg   
pkgbuild: Inferring bundle components from contents of payload/
pkgbuild: Adding top-level preinstall script
pkgbuild: Adding top-level postinstall script
pkgbuild: Wrote package to DesktopprManage-1.pkg

This works well, but it is difficult to memorize all the proper arguments for the pkgbuild command. If you forget or misconfigure one of them it will either fail outright or lead to a pkg installer file with unwanted behavior. I prefer to put the command in a script. Create a script file buildDesktopprManages.sh at the top of your project directory with these contents:

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

pkgname="DesktopprManage"
version="1.1"
identifier="com.scriptingosx.${pkgname}"
install_location="/"
minOSVersion="10.13"

# determine enclosing folder
projectfolder=$(dirname "$0")

# build the component package
pkgbuild --root "${projectfolder}/payload" \
         --identifier "${identifier}" \
         --version "${version}" \
         --ownership recommended \
         --install-location "${install_location}" \
         --scripts "${projectfolder}/scripts" \
         --min-os-version "${minOSVersion}" \
         --compression latest \
         "${pkgname}-${version}.pkg"

This way, you do not have to memorize the pkgbuild command and its arguments and it is easier to modify parameters such as the version. Remember to change the version as you build newer versions of this package going forward!

Some management systems deploy a pkg file using certain MDM commands. Then you need a signed distribution package instead of a simple component pkg file. (You can learn more about the different types of packages in this blog post or this MacDevOps YVR presentation. Building a sign distribution pkg is somewhat more involved. I have a template script for that in the repo.

Conclusion

As I said, building a LaunchAgent for desktoppr manage is not trivial. However, building LaunchAgents and LaunchDaemons and proper installer pkgs to deploy them is an important skill for Mac admins.

There are some other tools available, such as outset, or your management system might have options to run scripts at certain triggers. But in general, I recommend to understand the underlying processes and technologies before using tools, even when the tools are great and a good fit for the task at hand.

You can use this process of building a LaunchAgent and installer pkg for desktoppr as a template for your own scripts and projects. You can find all the files in the desktoppr repo example directory.