An Astrolabe setup app is a Swift executable target that depends on the
Astrolabe library and declares desired machine state in body.
The smallest useful setup looks like this:
import Astrolabe
@main
struct WorkstationSetup: Astrolabe {
var body: some Setup {
Pkg(.catalog(.commandLineTools))
Pkg(.catalog(.homebrew))
Brew("wget")
}
}When you run the executable as root, Astrolabe installs or starts its daemon, builds the declaration tree, and reconciles each leaf declaration.
Add Astrolabe to a Swift package
In your Package.swift, add the package dependency and link the Astrolabe
product from your executable target:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MySetup",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/photonlines/Astrolabe.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "MySetup",
dependencies: ["Astrolabe"]
),
]
)Use the AstrolabeUtils product only for a separate helper process that needs
shared storage access without the full reconciliation framework:
.executableTarget(
name: "MyTool",
dependencies: [
.product(name: "AstrolabeUtils", package: "Astrolabe"),
]
)Create the entry point
The entry point protocol is Astrolabe. It conforms to Setup, so your root
type has a body just like any reusable component.
import Astrolabe
@main
struct WorkstationSetup: Astrolabe {
@Environment(\.isEnrolled) private var isEnrolled
@Storage("preferredBrowser") private var preferredBrowser = "firefox"
init() {
Self.pollInterval = .seconds(10)
}
func onStart() async throws {
print("Starting WorkstationSetup")
}
func onExit() {
print("Stopping WorkstationSetup")
}
var body: some Setup {
Pkg(.catalog(.commandLineTools))
Pkg(.catalog(.homebrew))
Brew("wget")
if isEnrolled {
Brew("git-lfs")
Brew(preferredBrowser, type: .cask)
}
}
}Key details:
init()runs before the engine and is the right place to configure static knobs such aspollIntervalanddaemonMode.onStart()runs after persistence is loaded and before the first tick.onExit()runs on SIGTERM/SIGINT and should stay fast.bodyis evaluated many times. Keep it pure: no installs, no network calls, no filesystem writes.
Build and run on macOS
Build with SwiftPM:
swift buildRun with root privileges:
sudo .build/debug/MySetupRoot is required because Astrolabe manages system packages, /Library
launchd plists, and other machine-level state. The entry point checks getuid()
and throws if the process is not root.
Understand daemon mode
daemonMode defaults to true.
On the first root invocation, Astrolabe:
- Constructs your
Astrolabetype. - Sees that the process was not started by launchd.
- Writes
/Library/LaunchDaemons/codes.photon.astrolabe.plist. - Bootstraps the daemon with launchd.
- Exits the current process.
launchd then starts the same binary as a child of PID 1. That launchd child runs
the LifecycleEngine.
The daemon plist uses:
Label: codes.photon.astrolabe
ProgramArguments: [path-to-your-binary]
KeepAlive: true
RunAtLoad: true
StandardOutPath: /var/log/codes.photon.astrolabe.log
StandardErrorPath: /var/log/codes.photon.astrolabe.logThe label is currently fixed in DaemonManager; consumer apps cannot customize
it through public API.
Run inline during development
Set daemonMode to false when you want the engine to run in the current
process:
init() {
Self.daemonMode = false
}Inline mode removes any previously installed daemon and starts the engine directly. This is useful for local development and examples because logs stay in the foreground process.
Use inline mode carefully: reconciliation still makes real changes to the Mac. It is not a dry run.
Add a reusable component
Any Setup can be extracted into its own type:
struct DeveloperTools: Setup {
var body: some Setup {
Brew("swiftformat")
Brew("swiftlint")
Brew("git-lfs")
}
}
@main
struct WorkstationSetup: Astrolabe {
var body: some Setup {
Pkg(.catalog(.homebrew))
DeveloperTools()
}
}Reusable components should follow the same rule as the root: body declares
state and should not do side effects.
First mental model
Think of each leaf declaration as a small controller:
declared state -> mount if missing -> loop to detect drift -> mount again if driftedFor example:
Brew("wget")installswgetif needed.- Its
loop()checks whether the formula is still present. - If another process removes
wget, the loop reports drift. - Astrolabe routes that drift through the same mount pipeline.
The core engine never asks whether a mount "succeeded." The next loop() is the
truth source. That design keeps installation attempts, verification, and retry
behavior in one clear place.
Next steps
Read Core Concepts before building a complex setup. It
explains the synchronous tick, identity model, and why body must remain a pure
function of state.