Anthony’s Mac Labs Blog

📦 Feeding Outset with Packages using AutoPkg

Posted 2022 December 30

Outset is a great piece of enabling software written by Joe Chilcote. It gets rid of the drudgery of writing launch daemons for things (scripts, packages[1]) that you want to run after booting, logging in to an account, or even on demand. My primary use for Outset is to establish certain baseline settings for each student user in our computer labs. I place scripts in the login-once folder that either change settings directly (e.g., defaults write) or trigger other tools (e.g., dockutil, desktoppr). Unlike loginhooks (long-deprecated) and modifying the system template (a hack), this is a flexible, supported way to do that initial setup.

The question then becomes: how do you get those scripts (or packages) onto the system? The MDM solution we use at our University, Jamf Pro, is not good at dropping arbitrary files into specific locations on the system — you need to create a package installer to do this.[2] Jamf offers a tool to build such packages — Composer — but that is a manual process. I want to automate the packaging of those scripts for Outset. What are my choices?

Stand Alone Tools

I could use a tool like munkipkg or The Luggage to set up a map of what goes where (as per the particular methods of the app) and build a package using a simple command line command any time the script/payload changes. If you are already using either of these tools, you are well-equipped to do this already and the rest of this post will probably be just a curiosity. You could also use Apple’s pkgbuild tool directly, but the reason the previous tools exist is to make the process simpler than that. Maybe if you’re Armin Briegel or you are more DevOps than Mac Admin you do it that way, but that’s not me.

Putting the Pkg in AutoPkg

Users of AutoPkg will realize that it is good at packaging, but perhaps you will not have used it to build a package from something you created (rather than something you downloaded). I can build an AutoPkg recipe that takes a file (in this case, containing a script) and builds a package that installs it in the Outset folder I specify (usually login-once), making sure the file has the executable bit set. My recipe OutsetPayloadPkgReqd.pkg.recipe is one way to do this. Let’s examine this from the top.

Input Variables

There are two input variables: ACTION_TYPE specifies which Outset folder will receive the script payload and REVERSE_DOMAIN provides the first part of the package identifier (usually customized to your institution or business). I recommend making one override per Outset folder you wish to receive such scripts, renaming the override as you create it:

autopkg make-override OutsetPayloadPkgReqd.pkg -n Outset-login-once.pkg

One of the odd things you’ll notice about this recipe is that there is no NAME Input variable, as would be conventional. That is because I want to use it like a utility: every time I run the login-once override, it could be a different script I am packaging. That also explains the first processor, PackageRequired. Every time you run the recipe override, you will specify the location of the script file using the usual syntax:

autopkg run Outset-login-once.pkg --pkg /path/to/script.sh

Rather than type the path to the script file, I usually drag and drop it on the Terminal window once the rest of the command has been typed to add the path to the command.[3]

Naming the Package

Since the recipe does not require you to provide a name as an Input variable, I use the next few processors to automatically extract a suitable name from the filename. When you supply the payload using the --pkg (or -p) option, the path to the package is stored in the variable PKG. I use Rob Percival’s custom processor StringSplitter to first extract everything after the last slash (the script filename), then store that output in the variable name_with_ext, then try to strip the extension (which gets stored in out_string). I want to use the script name without extension as the tail of the package identifier, but it fails if there are any spaces or underscores, so I use Elliot Jordan’s FindAndReplace custom processor to eliminate those.

That leaves us with the three variables we will need when creating the package. As an example, if I ran this recipe with the payload being a file named Garbage_Collection 2023.sh:

Building the Package Payload Structure

When you build a package in AutoPkg, you need to create the directory structure where you want the file(s) to go. PkgRootCreator creates directories for this purpose. I need to create the path to /usr/local/outset/login-once (or whichever Outset folder I specified in ACTION_TYPE), so I create the hierarchy using the pkgdirs argument. I create these folders inside the recipe’s cache in a folder called payload (which is also created by this processor).[4] I came across one small problem when writing this recipe: the pkgdirs argument does not support variable substitution in the pathnames it creates. However, the FileMover processor gives me a nifty workaround: I name the target folder tmp when I create it but rename it to the value of ACTION_TYPE (usually login-once) using FileMover. Once that is done, I can copy the file that is specified at the command line into that folder.


The last little detail is that anything built by pkgbuild (which the PkgCreator processor leverages) needs to be assigned a version number. I could have let the user specify that by an Input variable, or I could have created a dependency that required any script deployed by this script to have a version number embedded within the script in a particular format that AutoPkg could parse. Instead, I wrote a processor that generates a version number based on the number of seconds that have passed since a particular fixed time.[5] Thus, whenever you run the recipe, the version number is always newer than the last run. VersionGenerator places that number (as a string) in the version variable as expected.

Make it so

The PkgCreator processor is what builds the package. It accepts a “package request dictionary,” whose key entries are documented in the autopkgserver code. The easiest way to understand how the PkgCreator arguments need to be configured is to look at another recipe that uses it and copy liberally (that is certainly what I do every time I use this processor). Once you examine prior art, it becomes a lot easier to understand. I also find that I sometimes need to change ownership on a file to match what it should be for its location on the file system; this is also done by this processor. My final package has the same name as the shell script name with the (multi-digit) version number appended as per normal convention. I then cleanup the payload directory because I am using this as a utility and I want that directory structure to be empty on every run.

Frequently Updated Script

I wrote this recipe to be as flexible and agile as possible, but perhaps your application of it would be for a particular script that you update more regularly — you’d rather not have to drag and drop the file onto Terminal every time. This is completely possible and you don’t even have to rewrite my recipe. It turns out that if you add the variable PKG to the Input variables in your override and set the value there, PackageRequired will treat it as if it was passed with the --pkg argument. So whether you adopt my more general approach (one override to generate packages for all my login-once scripts) or create a specific override for a particular script with that additional PKG variable, that’s up to you. (I actually do both.)

But Wait! There’s More!

So now I’ve explained what went into my recipe that takes any script you want to be executed by Outset and turns it into a versioned installer package to be pushed out by your management system. If you want to stop at the package automation and do the rest manually, that’s great — you already have profit! But I want to automate as much as possible. There are two more pieces to my automation puzzle: replacing a script that is already on the system and having Outset run it (which would normally not happen if the previous version had run), and uploading the package to Jamf Pro. But that will have to wait until a future post. Have a Happy New Year!

Updated 2023-01-01 with minor editoral changes.
Updated 2023-01-03 with a link to the subsequent related post (Part 2, if you will).

[1] In the Outset wiki, you will still see references to configuration profiles as a possible item for its deployment folders. This was true through macOS 10.15 Catalina. As of macOS 11 Big Sur, Apple no longer allows using the profiles command to install configuration profiles, so Outset does not deliver this payload on modern versions of macOS. [Return to main text]

[2] Jamf Pro does support using a disk image instead of a package, but not in the way you would normally think (especially if you are used to using Munki). You can build such a disk image using Composer, but as you are about to read, my goal is to automate packaging rather than using a manual process. [Return to main text]

[3] For the login-once override, this is actually a small fib. I use that override often enough that I have created a shell alias called outsetl1 for the first part of that command. So what I actually do is type outsetl1, then a space, then drop the script file onto the terminal, then hit Return. If aliases are new to you, I covered them in my AutoPkg Level Up talk at the 2018 Mac Admins Conference. The only major update is that I place the aliases in ~/.zshrc because my shell is now zsh. [Return to main text]

[4] By convention, you would place the folders for your package in a folder named using the value of the Input variable NAME, but this recipe doesn’t have that variable. An arbitrary variable name works just as well. [Return to main text]

[5] Currently, that time is 2021-05-21 23:00 UTC, but I have considered adding an “epoch” argument that would let you set your own starting time — pull request welcomed. [Return to main text]