A declarative macOS configuration framework. Describe the desired state of a machine -- packages, services, system settings -- and Astrolabe continuously converges reality to match.
Inspired by SwiftUI's programming model: you write a body that declares what should exist, and the framework figures out when and how to make it so.
import Astrolabe
@main
struct MySetup: Astrolabe {
@Environment(\.isEnrolled) var isEnrolled
var body: some Setup {
Pkg(.catalog(.commandLineTools))
Pkg(.catalog(.homebrew))
Brew("wget")
if isEnrolled {
Brew("git-lfs")
Brew("firefox", type: .cask)
Pkg(.gitHub("org/internal-tool"))
.retry(3)
.onFail { error in reportToMDM(error) }
LaunchAgent("com.example.myagent", program: "/usr/local/bin/myagent")
.runAtLoad()
.keepAlive()
.activate()
}
}
}Astrolabe runs as a persistent LaunchDaemon. On each tick:
- Read state -- snapshot environment values (enrollment status, console user, etc.)
- Build tree -- evaluate
bodywith current state to produce a declaration tree - Diff -- compare current tree leaves against previous leaves using content-based identity
- Reconcile -- enqueue mount/unmount tasks for additions and removals
Every node implements a single ReconcilableNode protocol with mount() and unmount() (both default to no-ops). Nodes override only what they need -- Sys and Jamf override mount(), Brew/Pkg/LaunchDaemon/LaunchAgent override unmount() and attach a bootstrap task that polls and self-installs.
The tick is fully synchronous. All async work (downloads, installs) runs in detached tasks. State changes from providers or @State mutations trigger the next tick automatically.
State Sources -> StateNotifier -> tick() -> Tree Diff -> TaskQueue -> Reconciler
| Type | Lifecycle | Purpose |
|---|---|---|
Brew("wget") |
unmount + bootstrap task | Homebrew formula or cask |
Pkg(.catalog(.homebrew)) |
unmount + bootstrap task | Non-Homebrew packages (catalog, GitHub .pkg, custom) |
Sys(.hostname("name")) |
mount only | System configuration |
Jamf(.computerName("name")) |
mount only | Jamf configuration |
LaunchDaemon(label, program:) |
unmount + bootstrap task | System-level launchd service |
LaunchAgent(label, program:) |
unmount + bootstrap task | Per-user launchd service |
Anchor() |
no-op | Modifier-only attachment point |
// Homebrew
Brew("wget")
Brew("firefox", type: .cask)
// Packages
Pkg(.catalog(.commandLineTools))
Pkg(.gitHub("org/tool", version: .tag("v2.0")))
// Launchd services
LaunchDaemon("com.example.daemon", program: "/usr/local/bin/daemon")
.keepAlive()
.standardOutPath("/var/log/daemon.log")
.activate()
LaunchAgent("com.example.agent", program: "/usr/local/bin/agent")
.runAtLoad()
.environmentVariables(["KEY": "value"])
.activate() // bootstraps for every logged-in user
// System config
Sys(.hostname("dev-mac"))Composable -- group related declarations into reusable components:
struct DevTools: Setup {
var body: some Setup {
Brew("swiftformat")
Brew("swiftlint")
Brew("git-lfs")
}
}In-memory only. Resets on daemon restart. Mutations trigger re-evaluation.
@State var showWelcome = trueLike @State, but persisted to disk -- survives daemon restart. Accepts any Codable value.
@Storage("hasCompletedOnboarding") var hasCompletedOnboarding = false
@Storage("preferredBrowser") var preferredBrowser: String = "firefox"Read-only values derived from the system by polling providers:
@Environment(\.isEnrolled) var isEnrolledA built-in provider checks MDM enrollment status. Custom providers conform to StateProvider.
Brew("wget")
.retry(3, delay: .seconds(10)) // retry on failure
.onFail { error in log(error) } // error callback
.preInstall { await validate() } // pre-install hook
.postInstall { await configure() } // post-install hook
Pkg(.gitHub("org/tool"))
.allowUntrusted() // unsigned packages
.preUninstall { await backup() } // pre-uninstall hook
Group {
Pkg(.gitHub("private/repo1"))
Pkg(.gitHub("private/repo2"))
}
.environment(\.gitHubToken, token) // config propagation
Group {
LaunchAgent("com.example.a", program: "/usr/local/bin/a")
LaunchAgent("com.example.b", program: "/usr/local/bin/b")
}
.runAtLoad() // launchd plist config
.keepAlive() // propagates through Group
.activate() // immediate bootstrapping
Brew("iterm2", type: .cask)
.dialog("Welcome!", message: "Mac is ready.",
isPresented: $showWelcome) {
Button("Get Started")
}
Pkg(.catalog(.homebrew))
.task { await setupBrewTaps() } // lifecycle-bound async work
Anchor()
.onChange(of: isEnrolled) { old, new in
print("Enrollment changed: \(old) → \(new)")
}@main
struct MySetup: Astrolabe {
init() {
Self.pollInterval = .seconds(10)
}
func onStart() async throws {
// Runs after persistence loads, before first tick.
// Fetch config, authenticate, pre-clean state.
}
func onExit() {
// Runs on SIGTERM/SIGINT. Keep it fast.
}
var body: some Setup { ... }
}Startup sequence: root check -> daemon install -> load PayloadStore -> load StorageStore -> onStart() -> seed providers -> first tick -> poll loop.
- macOS 14+
- Swift 6.2+
Separate lightweight package for accessing Astrolabe's persistent storage from other processes on the Mac. No dependency on the full framework.
import AstrolabeUtils
let client = StorageClient()
let browser: String? = client.read("preferredBrowser")
try client.write("preferredBrowser", value: "safari")Add Astrolabe as a dependency in your Package.swift:
dependencies: [
.package(url: "https://github.com/photonlines/Astrolabe.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "MySetup",
dependencies: ["Astrolabe"]
),
]For other processes that only need storage access:
targets: [
.executableTarget(
name: "MyTool",
dependencies: [
.product(name: "AstrolabeUtils", package: "Astrolabe"),
]
),
]Build and run with root privileges (required for package installation and LaunchDaemon registration):
swift build
sudo .build/debug/MySetupOn first run, Astrolabe installs itself as a LaunchDaemon (codes.photon.astrolabe) with KeepAlive and RunAtLoad enabled. After that, launchd keeps it running.
See the Examples/ directory:
- BasicSetup -- minimal configuration installing a few Homebrew packages
- ConditionalSetup -- declarations gated on environment values like enrollment status
- GroupModifiers -- applying retry policies and modifiers to groups of declarations
See DESIGN.md for the full architecture, including the SwiftUI mapping, state system, concurrency model, and reconciliation strategy.