diff --git a/AGENTS.md b/AGENTS.md index aa8781a..19dbbbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Cursor Cloud specific instructions ### What this is -Astrolabe is a **macOS-only** Swift Package (SwiftPM, `swift-tools-version: 6.2`, +Astrolabe is a **macOS-only** Swift Package (SwiftPM, `swift-tools-version: 6.3`, `platforms: [.macOS(.v15)]`). It is a declarative macOS configuration framework that runs as a LaunchDaemon and reconciles system state (Homebrew, launchd, Jamf, system settings). It ships two libraries (`Astrolabe`, `AstrolabeUtils`) plus example @@ -19,7 +19,8 @@ run on macOS 15+**. The package's own targets import macOS-only modules with no `RecordStore.swift`), plus runtime use of `launchd` and `launchctl`. Consequence on the Linux VM: -- `swift package resolve` and `swift package describe` **work**. +- `swift package resolve` and `swift package describe` **work** with Swift 6.3+ + (see the toolchain note below). - `swift build` compiles the entire third-party dependency graph (NIO, gRPC, swift-crypto/BoringSSL, OpenTelemetry, ArgumentParser, etc.) successfully, then **fails** on the first Astrolabe target with `error: no such module 'Darwin'`. @@ -29,8 +30,10 @@ Consequence on the Linux VM: that is the explicit task — it is an environment limitation, not a repo bug. ### Toolchain on the Linux VM -- Swift **6.2.4** is installed via `swiftly` (matches `swift-tools-version: 6.2`). - It is on `PATH` for login shells. In a non-login/non-interactive context, call it +- The package needs Swift **6.3+** (`swift-tools-version: 6.3`; `@Resource` emits + `@section`/`@used`, SE-0492). The VM snapshot was set up with Swift **6.2.4** via + `swiftly`, which can't read the manifest: install a 6.3+ toolchain with `swiftly` + first. It is on `PATH` for login shells. In a non-login/non-interactive context, call it by absolute path `"$HOME/.local/share/swiftly/bin/swift"` or source `"$HOME/.local/share/swiftly/env.sh"` first. - C++ dependencies (swift-nio-ssl's bundled BoringSSL) require C++ stdlib headers. diff --git a/Examples/BrewEngine/BrewApp.swift b/Examples/BrewEngine/BrewApp.swift new file mode 100644 index 0000000..3f910e4 --- /dev/null +++ b/Examples/BrewEngine/BrewApp.swift @@ -0,0 +1,112 @@ +import Astrolabe +import Foundation + +/// Exercises `Brew` on a farm Mac or a clean Apple-silicon VM. +/// +/// Write the owner's short name (`whoami`) to `/etc/astrolabe-homebrew-owner`, and the packages +/// to `/etc/astrolabe-brew-packages`, one per line: +/// +/// ``` +/// wget +/// cask firefox +/// tailscale >= 1.102.0 +/// ``` +/// +/// ```sh +/// echo ops | sudo tee /etc/astrolabe-homebrew-owner +/// printf 'wget\ncask firefox\n' | sudo tee /etc/astrolabe-brew-packages.new >/dev/null +/// sudo mv /etc/astrolabe-brew-packages.new /etc/astrolabe-brew-packages +/// sudo .build/release/BrewEngine --dry-run # rollout brew:formula:wget@…, once Homebrew is there +/// sudo .build/release/BrewEngine # installs the daemon +/// ``` +/// +/// Write the list to a temp file and `mv` it into place: `sudo tee` truncates first, and a read +/// in that instant would see an empty list and uninstall everything. +/// +/// Add a line and the package installs. Remove it and it's uninstalled. Run +/// `brew as-brew-user uninstall wget` and it's back within a minute. +@main +struct BrewApp: Astrolabe { + @Fact(\.homebrewOwner) var owner + @Fact(\.brewList) var packages + + var body: some Component { + CommandLineTools() + if let owner { + Homebrew(owner: owner) + } + for package in packages { + Brew(package.name, type: package.type, version: package.version) + } + } +} + +extension Facts { + /// The first line of `/etc/astrolabe-homebrew-owner`, or `nil` when it's missing or empty. + @FactEntry(schedule: .every(.seconds(5)), read: { OwnerFile.read() }) + var homebrewOwner: String? + + /// A missing file reads as `[]`: nothing declared. An unreadable file or an unparseable line + /// throws, so the engine keeps the last list rather than uninstalling everything. + @FactEntry(schedule: .every(.seconds(5)), read: { try PackageFile.read() }) + var brewList: [ListedPackage] = [] +} + +/// One line of `/etc/astrolabe-brew-packages`. +struct ListedPackage: Equatable, Sendable { + let name: String + let type: Brew.PackageType + let version: Brew.Version +} + +enum OwnerFile { + static let path = "/etc/astrolabe-homebrew-owner" + + /// Never throws: a missing file is a real reading, "no owner", not a failed one. + static func read() -> String? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + let owner = text.split(whereSeparator: \.isNewline).first?.trimmingCharacters(in: .whitespaces) ?? "" + return owner.isEmpty ? nil : owner + } +} + +/// `[cask] [>= ]` per line. Blank lines and `#` comments are skipped. +enum PackageFile { + static let path = "/etc/astrolabe-brew-packages" + + struct UnparseableLine: Error, CustomStringConvertible { + let number: Int + let line: String + var description: String { "\(PackageFile.path):\(number): expected `[cask] [>= ]`, got `\(line)`" } + } + + static func read() throws -> [ListedPackage] { + guard FileManager.default.fileExists(atPath: path) else { return [] } + let text = try String(contentsOfFile: path, encoding: .utf8) + var packages: [ListedPackage] = [] + for (index, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + let content = line.prefix { $0 != "#" }.trimmingCharacters(in: .whitespaces) + guard !content.isEmpty else { continue } + guard let package = parse(content) else { throw UnparseableLine(number: index + 1, line: String(line)) } + packages.append(package) + } + return packages + } + + private static func parse(_ line: String) -> ListedPackage? { + var words = line.split(whereSeparator: \.isWhitespace).map(String.init) + var type = Brew.PackageType.formula + if words.first == "cask" { + type = .cask + words.removeFirst() + } + switch words.count { + case 1: + return ListedPackage(name: words[0], type: type, version: .installed) + case 3 where words[1] == ">=": + return ListedPackage(name: words[0], type: type, version: .atLeast(words[2])) + default: + return nil + } + } +} diff --git a/Examples/HomebrewEngine/HomebrewApp.swift b/Examples/HomebrewEngine/HomebrewApp.swift new file mode 100644 index 0000000..6772117 --- /dev/null +++ b/Examples/HomebrewEngine/HomebrewApp.swift @@ -0,0 +1,47 @@ +import Astrolabe +import Foundation + +/// Exercises `Homebrew` on a farm Mac or a clean Apple-silicon VM, ideally at the login window. +/// +/// Write the owner's short name (`whoami`) to `/etc/astrolabe-homebrew-owner`, then run it: +/// +/// ```sh +/// echo ops | sudo tee /etc/astrolabe-homebrew-owner +/// sudo .build/release/HomebrewEngine --dry-run # rollout commandLineTools@…; no homebrew until CLT is there +/// sudo .build/release/HomebrewEngine # installs the daemon +/// ``` +/// +/// CLT installs first; within about a minute of it landing, Homebrew does, owned by the owner. +/// Write another account to the file and the prefix is re-owned, with nothing downloaded. Empty +/// the file and Homebrew is no longer declared, which leaves it in place. +/// +/// The owner comes from a file, not an environment variable: the daemon launchd starts never +/// sees the environment of the `sudo` that installed it. +@main +struct HomebrewApp: Astrolabe { + @Fact(\.homebrewOwner) var owner + + var body: some Component { + CommandLineTools() + if let owner { + Homebrew(owner: owner) + } + } +} + +extension Facts { + /// The first line of `/etc/astrolabe-homebrew-owner`, or `nil` when it's missing or empty. + @FactEntry(schedule: .every(.seconds(5)), read: { OwnerFile.read() }) + var homebrewOwner: String? +} + +enum OwnerFile { + static let path = "/etc/astrolabe-homebrew-owner" + + /// Never throws: a missing file is a real reading, "no owner", not a failed one. + static func read() -> String? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + let owner = text.split(whereSeparator: \.isNewline).first?.trimmingCharacters(in: .whitespaces) ?? "" + return owner.isEmpty ? nil : owner + } +} diff --git a/Examples/StagingEngine/StagingApp.swift b/Examples/StagingEngine/StagingApp.swift index be8704a..0d29dcc 100644 --- a/Examples/StagingEngine/StagingApp.swift +++ b/Examples/StagingEngine/StagingApp.swift @@ -27,6 +27,4 @@ struct StagingApp: Astrolabe { StagingFile(path: "\(StateFile.directory)/\(name)", contents: contents) } } - - static var resourceTypes: [any Resource.Type] { [StagingFile.self] } } diff --git a/Examples/StagingEngine/StagingFile.swift b/Examples/StagingEngine/StagingFile.swift index 683d2ac..bbd6995 100644 --- a/Examples/StagingEngine/StagingFile.swift +++ b/Examples/StagingEngine/StagingFile.swift @@ -2,7 +2,8 @@ import Astrolabe import Foundation /// A file with fixed contents. Staging only: not part of the library. -struct StagingFile: Resource { +@Resource +struct StagingFile { static let kind = "staging.file" let path: String let contents: String diff --git a/Package.resolved b/Package.resolved index 5417ea6..c645715 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "26480fd7f34b7e06cd576ec86f1c5742af75aeaea9db68bff6c70cea64e8a828", + "originHash" : "55f241d3b32d9ac02f0d01fb73626833613407fc629ca90d948e2a7e173a5070", "pins" : [ { "identity" : "async-http-client", @@ -118,6 +118,15 @@ "version" : "4.9.3" } }, + { + "identity" : "semaphore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/Semaphore.git", + "state" : { + "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", + "version" : "0.1.0" + } + }, { "identity" : "signozswift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 890797a..5e46a71 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport @@ -19,6 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/groue/Semaphore.git", from: "0.1.0"), .package(url: "https://github.com/photon-hq/SignozSwift.git", branch: "main"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "1.0.0"), .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"), @@ -40,6 +41,7 @@ let package = Package( "AstrolabeUtils", "AstrolabeMacros", .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Semaphore", package: "Semaphore"), .product(name: "SignozSwift", package: "SignozSwift"), .product(name: "Subprocess", package: "swift-subprocess"), ] @@ -68,5 +70,15 @@ let package = Package( dependencies: ["Astrolabe"], path: "Examples/StagingEngine" ), + .executableTarget( + name: "HomebrewEngine", + dependencies: ["Astrolabe"], + path: "Examples/HomebrewEngine" + ), + .executableTarget( + name: "BrewEngine", + dependencies: ["Astrolabe"], + path: "Examples/BrewEngine" + ), ] ) diff --git a/README.md b/README.md index f7188d6..45de5ab 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,56 @@ > turns it into a declaration of `id@hash` resources, and makes the Mac match; `records.json` > keeps the last successful snapshot per id. A pass that reads a fact before its first read has > landed acts on nothing, so nothing is removed or rolled out on a default. The foundation ships -> `Component`, `Resource`, `@Fact`, the engine, and the records — no built-in facts or resources -> yet. See `Examples/StagingEngine` for a complete app, and run `sudo --dry-run` to -> print the diff without acting. +> `Component`, `Resource`, `@Fact`, the engine, and the records. See `Examples/StagingEngine` for +> a complete app, and run `sudo --dry-run` to print the diff without acting. +> +> Built in so far (ENG-3739): `CommandLineTools()` installs the Xcode Command Line Tools through +> `softwareupdate` when they're missing, headlessly, and never updates them. +> `CommandLineTools(version: .latest)` also keeps them at the newest Apple offers this macOS: +> it checks the catalog every 6 hours, but never on the first read after launch. Neither +> downgrades or uninstalls. Two facts sit under `\.commandLineTools`: `installed`, the version on +> the Mac, and `latestAvailable`, the newest Apple offers or the installed version when that's +> newer. Hash `installed` into a resource that must redo work after a CLT update. It reads `nil` +> whenever CLT is missing, such as after someone deletes it, and anything declared behind +> `if installed != nil` is then removed: gate only resources whose `remove` does nothing. +> +> `@Monitor` (ENG-3751) sits next to `@Fact`: it reads a fact into a resource when `body` builds +> it, and when that value changes the engine reruns `rollout` toward the same hash, without +> cancelling one in flight. `CommandLineTools` monitors `\.commandLineTools.isInstalled`, so a +> deleted CLT is installed again, while an update made outside Astrolabe starts nothing. Under +> `.latest`, that update still moves the declared `minimum`, as it always has: one rollout that +> does nothing. +> +> `Homebrew(owner: "ops")` (ENG-3749) keeps Homebrew at `/opt/homebrew`, owned by one account, +> and installs it headlessly with Homebrew's own `install.sh`. `owner` is that account's short +> name (what `whoami` prints), never root, and there's one per Mac: every `brew` runs as it. +> `install.sh` refuses root and needs `sudo` only to create the prefix in root's `/opt`, so the +> daemon creates it and gives it to `owner`, then runs the script as `owner` with +> `NONINTERACTIVE=1`: no password, even at the login window. Declare `CommandLineTools()` too: +> Homebrew never installs CLT, and without it waits silently (the dry run shows no `homebrew` +> id). People run `brew` by hand as the owner, with `brew as-brew-user `. Through +> `@Monitor(\.homebrew.owners)`, anything another account creates in the prefix is `chown`ed back +> within a minute, with nothing downloaded, and a deleted Homebrew is installed again. Two facts +> sit under `\.homebrew`: `owners`, every account that owns part of it, and `isInstalled`. +> Apple silicon only, as Homebrew is on macOS. Dropping it from `body` never uninstalls. +> +> `Brew("wget")` (ENG-3738) keeps a formula installed, and `Brew("firefox", type: .cask)` a +> cask. `Brew("tailscale", version: .atLeast("1.102.0"))` also runs `brew upgrade` while the +> installed version is below the floor; Homebrew installs its latest, and changing `version` +> never uninstalls. The id is `brew::`, so tap-qualifying a name +> (`cloudflare/cloudflare/cloudflared`) keeps the id: nothing is uninstalled, and the rollout +> finds it installed. Dropping a `Brew` from `body` uninstalls it, also after a restart, from +> `records.json`. Every `brew` runs as Homebrew's one owner, one at a time, under the lock +> `Homebrew` holds for `install.sh` and a re-own. A third fact, `\.homebrew.packages`, scans the +> Cellar and Caskroom every minute without running `brew`, and each `Brew` monitors whether it's +> satisfied, so a hand-run `brew uninstall`, or a downgrade below the floor, is undone within a +> minute. `Brew` waits on `\.homebrew.isInstalled`, never on one owner: when another admin runs +> `brew install`, mixed owners would hide every package, and a hidden package is uninstalled. +> Declare `Homebrew(owner:)` too. Without it, `Brew` waits silently on a clean Mac, and on a Mac +> that has Homebrew it runs as the owner, failing and retrying while the owners are mixed. Read +> `\.homebrew.packages` in your own components with `@Fact`, e.g. to declare something only once +> a binary is installed. Use canonical names (`python@3.13`, not the alias `python3`): an alias +> never reads as installed, so it fails with `didNotInstall` forever. See `Examples/BrewEngine`. > > Everything below documents v1, which stays on `main`. @@ -317,7 +364,7 @@ System probes (SIP status, MDM enrollment) read the machine through a subprocess ## Requirements - macOS 15+ -- Swift 6.2+ +- Swift 6.3+ Astrolabe spawns child processes through [swift-subprocess](https://github.com/swiftlang/swift-subprocess), which it pulls in transitively. Consumer packages that need to shell out should depend on it directly -- Astrolabe's `ProcessRunner` is internal. diff --git a/Sources/Astrolabe/Astrolabe.swift b/Sources/Astrolabe/Astrolabe.swift index 2caa24b..b4332de 100644 --- a/Sources/Astrolabe/Astrolabe.swift +++ b/Sources/Astrolabe/Astrolabe.swift @@ -18,19 +18,15 @@ import Foundation /// StagingFile(path: "/tmp/astrolabe-staging/\(name)", contents: contents) /// } /// } -/// -/// static var resourceTypes: [any Resource.Type] { [StagingFile.self] } /// } /// ``` +/// +/// Resource types aren't listed: `@Resource` records each one in the binary, and the engine +/// finds them all at launch. @MainActor public protocol Astrolabe: Component, SendableMetatype { init() - /// Every resource type the app can declare, so records written by an earlier run can be - /// decoded after a restart even if `body` doesn't declare that type yet. Types seen in a - /// declaration are registered too. Default: none. - static var resourceTypes: [any Resource.Type] { get } - /// Telemetry sink for operational events. /// /// Default: `NoopAstrolabeTelemetry()` — Astrolabe sends no telemetry. @@ -39,7 +35,6 @@ public protocol Astrolabe: Component, SendableMetatype { } extension Astrolabe { - public static var resourceTypes: [any Resource.Type] { [] } public static var telemetry: AstrolabeTelemetry { NoopAstrolabeTelemetry() } /// Entry point called by the Swift runtime when this type is marked `@main`. diff --git a/Sources/Astrolabe/BuiltIn/Brew/Brew.swift b/Sources/Astrolabe/BuiltIn/Brew/Brew.swift new file mode 100644 index 0000000..e5431ee --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/Brew/Brew.swift @@ -0,0 +1,109 @@ +/// Keeps a Homebrew formula or cask installed, and at or above a minimum version. +/// +/// ```swift +/// var body: some Component { +/// CommandLineTools() +/// Homebrew(owner: "ops") +/// Brew("wget") +/// Brew("firefox", type: .cask) +/// Brew("tailscale", version: .atLeast("1.102.0")) +/// } +/// ``` +/// +/// Waits until Homebrew is on the Mac. Declare `Homebrew(owner:)` so it gets installed and kept +/// owned by one account; without it, `Brew` runs as whoever owns an existing Homebrew. Every +/// `brew` runs as that owner, one at a time. A hand-run `brew uninstall` is undone within a +/// minute. Dropping it from `body` uninstalls it. Changing `version` or tap-qualifying `name` +/// never does. Use canonical names (`python@3.13`, not the alias `python3`). +public struct Brew: Component { + public enum PackageType: String, Sendable, Hashable, Codable { + case formula + case cask + } + + /// Which installed versions satisfy this declaration. Not part of the id: changing it + /// upgrades in place and never uninstalls. + public enum Version: Sendable, Hashable { + /// Any installed version. Never upgrades. The default. + case installed + /// `brew upgrade` while the installed version is below this. Homebrew installs its + /// latest release, not this version itself. + case atLeast(String) + } + + let name: String + let type: PackageType + let version: Version + @Fact(\.homebrew.isInstalled) private var homebrewInstalled + + public init(_ name: String, type: PackageType = .formula, version: Version = .installed) { + self.name = name + self.type = type + self.version = version + } + + public var body: some Component { + // Gated on `isInstalled`, never on one owner: mixed owners would hide every package, + // and a hidden package is uninstalled. `isInstalled` goes false only when `bin/brew` is + // gone, and then the Cellar usually is too, so `remove` finds nothing to remove. + if homebrewInstalled { + BrewPackage(name: name, type: type, minimum: version.minimum) + } + } +} + +extension Brew.Version { + /// `.atLeast`'s floor, or `nil` for `.installed`. + var minimum: String? { + switch self { + case .installed: nil + case .atLeast(let minimum): minimum + } + } +} + +/// Keeps one formula or cask installed and at `minimum` or above. Declared by `Brew`. +@Resource +struct BrewPackage { + static let kind = "astrolabe.brew" + /// As declared, maybe tap-qualified (`cloudflare/cloudflare/cloudflared`). `brew install` gets it. + let name: String + let type: Brew.PackageType + /// `.atLeast`'s floor, or `nil` for any installed version. + let minimum: String? + /// Installed and at or above `minimum`. A hand-run `brew uninstall` or a downgrade (false) + /// reruns `rollout`. An upgrade made outside Astrolabe changes nothing. + @Monitor var satisfied: Bool? + + init(name: String, type: Brew.PackageType, minimum: String?) { + self.name = name + self.type = type + self.minimum = minimum + _satisfied = Monitor(\.homebrew[satisfies: name, type, minimum]) + } + + /// The short name: tap-qualifying a name rolls out the same id rather than installing one + /// keg and uninstalling the same keg, with no order between the two. + var id: String { "brew:\(type.rawValue):\(BrewName.short(name))" } + + static func compute(_ package: Self) -> String { + Hash.of(kind, package.name, package.type.rawValue, package.minimum) + } + + @concurrent static func rollout(_ package: Self) async throws { + try await BrewInstaller.ensure(package.name, type: package.type, minimum: package.minimum) + } + + /// Uninstalls, as v1 did. Uses only what the id is built from. Not there: a no-op. + @concurrent static func remove(_ package: Self) async throws { + try await BrewInstaller.uninstall(package.name, type: package.type) + } + + /// Failures are mostly network, Homebrew missing or mid re-own (fixed within a minute), or + /// a floor Homebrew doesn't offer yet. + static var retryPolicy: RetryPolicy { .retry(Backoff(initial: .seconds(60), max: .seconds(3600))) } + + /// Mostly waiting for the lock: on first boot every package queues behind the others. + /// Each `brew` command is capped by `ProcessRunner.Timeout.install` (1 h) on its own. + static var timeout: Duration { .seconds(4 * 3600) } +} diff --git a/Sources/Astrolabe/BuiltIn/Brew/BrewFact.swift b/Sources/Astrolabe/BuiltIn/Brew/BrewFact.swift new file mode 100644 index 0000000..52a8997 --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/Brew/BrewFact.swift @@ -0,0 +1,41 @@ +extension HomebrewFacts { + /// Every formula and cask Homebrew has installed, with the version `brew` runs. Read from + /// the prefix alone: `readdir` and `readlink`, no `brew`, so every minute is cheap. + /// + /// Read without the Homebrew lock. It only feeds `@Monitor`s, and a value caught mid-install + /// just reruns a rollout that checks again under the lock. With the lock, a first boot that + /// queues every package would also hold up drift detection. + @FactEntry(schedule: .every(.seconds(60)), read: { try HomebrewReads.packages() }) + public var packages: BrewPackages = BrewPackages() + + /// Whether `name` (short or tap-qualified) is installed as `type`, at `minimum` or above + /// (any version when `nil`). Tracked through `packages`. What `BrewPackage` monitors: one + /// `Bool` per package, so installing one package doesn't rerun every other. + subscript(satisfies name: String, type: Brew.PackageType, minimum: String?) -> Bool { + BrewInstaller.check(packages, name, type: type, minimum: minimum) == .satisfied + } +} + +/// What Homebrew has installed. Each version is what `brew` runs: for a formula, the `opt` +/// link's target, otherwise (and for every cask) the highest version directory. +public struct BrewPackages: Equatable, Sendable { + /// Short name → version. + public var formulae: [String: String] + /// Token → version. + public var casks: [String: String] + + public init(formulae: [String: String] = [:], casks: [String: String] = [:]) { + self.formulae = formulae + self.casks = casks + } + + /// The installed version of `name` (short or tap-qualified) as `type`, or `nil` when it + /// isn't installed. + public func version(of name: String, type: Brew.PackageType) -> String? { + let short = BrewName.short(name) + return switch type { + case .formula: formulae[short] + case .cask: casks[short] + } + } +} diff --git a/Sources/Astrolabe/BuiltIn/Brew/BrewInstaller.swift b/Sources/Astrolabe/BuiltIn/Brew/BrewInstaller.swift new file mode 100644 index 0000000..bddd68e --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/Brew/BrewInstaller.swift @@ -0,0 +1,153 @@ +/// Formula and cask names as Homebrew's directories key them. +enum BrewName { + /// `cloudflare/cloudflare/cloudflared` → `cloudflared`; `wget` → `wget`. Cellar, Caskroom + /// and ids key on this. Shortening twice changes nothing. + static func short(_ name: String) -> String { + let components = name.split(separator: "/") + // A tap-qualified name is user/tap/name. + return components.count == 3 ? String(components[2]) : name + } +} + +extension Brew.PackageType { + /// `--formula` or `--cask`. Every `brew` names the type, so a formula and a cask with one + /// name are never ambiguous. + var flag: String { "--\(rawValue)" } +} + +/// Where an installed package stands against its declared minimum. +enum BrewCheck: Equatable { + case notInstalled + case satisfied + case below(installed: String, minimum: String) + /// An unparseable version on either side: a `HEAD-…` keg, a cask's `latest`, a `v1.2` floor. + case uncomparable(installed: String, minimum: String) +} + +/// Installs, upgrades and uninstalls formulae and casks, as Homebrew's one owner. +enum BrewInstaller { + /// Pure: where `name` (short or tap-qualified; shortened here) stands in `packages` against + /// `minimum`. Shared by the monitor subscript, rollout's no-lock check, its locked check, + /// and its verify. + static func check(_ packages: BrewPackages, _ name: String, type: Brew.PackageType, minimum: String?) -> BrewCheck { + guard let installed = packages.version(of: name, type: type) else { return .notInstalled } + guard let minimum else { return .satisfied } + guard let current = BrewVersion(installed), let floor = BrewVersion(minimum) else { + return .uncomparable(installed: installed, minimum: minimum) + } + return current < floor ? .below(installed: installed, minimum: minimum) : .satisfied + } + + /// Makes sure `name` is installed as `type`, at `minimum` or above: `brew install` when it's + /// missing, `brew upgrade` when it's below. Never upgrades a version it can't compare. + /// + /// Checks the Mac first, without the lock: a satisfied package never waits on another's + /// install. Every `brew` runs under `HomebrewContext.lock`, as the one owner. + static func ensure(_ name: String, type: Brew.PackageType, minimum: String?) async throws { + let context = HomebrewContext.current + let system = context.system + guard try needsBrew(check(try system.packages(), name, type: type, minimum: minimum), name) else { return } + + try await HomebrewInstaller.locked(context) { + try Task.checkCancellation() + let found = check(try system.packages(), name, type: type, minimum: minimum) + guard try needsBrew(found, name) else { return } // another rollout just did it + let owner = try owner(system) + let verb = found == .notInstalled ? "install" : "upgrade" + // The declared name: a tap-qualified one taps it. + try await system.brew([verb, type.flag, name], as: owner) + + switch check(try system.packages(), name, type: type, minimum: minimum) { + case .satisfied: + return + case .notInstalled: + throw BrewError.didNotInstall(name: name) + case .below(let installed, let minimum): + throw BrewError.minimumUnavailable(name: name, installed: installed, minimum: minimum) + case .uncomparable(let installed, let minimum): + throw BrewError.uncomparableVersion(name: name, installed: installed, minimum: minimum) + } + } + } + + /// Uninstalls `name` as `type`, if it's installed. Only the short name and the type: what an + /// id is built from, and all a removal can rely on. + static func uninstall(_ name: String, type: Brew.PackageType) async throws { + let context = HomebrewContext.current + let system = context.system + let short = BrewName.short(name) + guard try system.packages().version(of: short, type: type) != nil else { return } + + try await HomebrewInstaller.locked(context) { + // Declared again while waiting: the engine cancelled this, and nothing is uninstalled. + try Task.checkCancellation() + guard try system.packages().version(of: short, type: type) != nil else { return } + let owner = try owner(system) + try await system.brew(["uninstall", type.flag, short], as: owner) + guard try system.packages().version(of: short, type: type) == nil else { + throw BrewError.didNotUninstall(name: short) + } + } + } + + // MARK: - Private + + /// Whether `found` needs a `brew`. An uncomparable version throws: it never upgrades. + private static func needsBrew(_ found: BrewCheck, _ name: String) throws -> Bool { + switch found { + case .satisfied: + false + case .notInstalled, .below: + true + case .uncomparable(let installed, let minimum): + throw BrewError.uncomparableVersion(name: name, installed: installed, minimum: minimum) + } + } + + /// Call under the lock: the one account that owns Homebrew, from `system.owners()`. `[]` + /// throws `homebrewMissing`. Several owners, a uid with no account (`#501`), or root throw + /// `homebrewNotReady`. + /// + /// Never `HomebrewReads.owners()`: it takes the same lock, and would wait on itself. + private static func owner(_ system: any HomebrewSystem) throws -> String { + let owners = try system.owners() + guard !owners.isEmpty else { throw BrewError.homebrewMissing } + guard owners.count == 1, let owner = owners.first, let uid = system.uid(of: owner), uid != 0 else { + throw BrewError.homebrewNotReady(owners: owners) + } + return owner + } +} + +enum BrewError: Error, Equatable, Sendable, CustomStringConvertible { + /// `brew` isn't at the prefix. The app declares `Homebrew(owner:)`; `Brew` never installs it. + case homebrewMissing + /// Homebrew is owned by several accounts, by root, or by a uid with no account. `Homebrew` + /// re-owns it within a minute, and the retry runs as the one owner. + case homebrewNotReady(owners: [String]) + /// `brew install` succeeded, but the package isn't in the Cellar or Caskroom: often an alias. + case didNotInstall(name: String) + /// `brew uninstall` succeeded, but the package is still there. + case didNotUninstall(name: String) + /// The installed version or the declared minimum doesn't parse as a Homebrew version. + case uncomparableVersion(name: String, installed: String, minimum: String) + /// `brew upgrade` succeeded, but Homebrew's latest is still below the minimum. + case minimumUnavailable(name: String, installed: String, minimum: String) + + var description: String { + switch self { + case .homebrewMissing: + "Homebrew isn't installed: declare Homebrew(owner:)" + case .homebrewNotReady(let owners): + "Homebrew is owned by \(owners.joined(separator: ", ")): waiting for one owner that isn't root" + case .didNotInstall(let name): + "brew install \(name) finished, but \(BrewName.short(name)) isn't installed (an alias? use the canonical name)" + case .didNotUninstall(let name): + "brew uninstall \(name) finished, but \(name) is still installed" + case .uncomparableVersion(let name, let installed, let minimum): + "brew \(name): installed version \(installed) can't be compared to minimum \(minimum)" + case .minimumUnavailable(let name, let installed, let minimum): + "brew \(name): Homebrew's latest (\(installed)) is below minimum \(minimum)" + } + } +} diff --git a/Sources/Astrolabe/BuiltIn/Brew/BrewSystem.swift b/Sources/Astrolabe/BuiltIn/Brew/BrewSystem.swift new file mode 100644 index 0000000..17e287c --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/Brew/BrewSystem.swift @@ -0,0 +1,104 @@ +import Darwin +import Foundation + +extension InstallScriptHomebrew { + func packages() throws -> BrewPackages { + try BrewScan.packages(prefix: Self.prefix) + } + + func brew(_ arguments: [String], as user: String) async throws { + guard let context = UserContext(username: user)?.prependingPath("\(Self.prefix)/bin") else { + throw HomebrewError.unknownOwner(user) + } + print("[Astrolabe] brew \(arguments.joined(separator: " ")) as \(user)") + try await ProcessRunner.stream(Self.brew, arguments: arguments, as: context, timeout: ProcessRunner.Timeout.install) { line in + print("[Astrolabe] brew: \(line)") + } + } +} + +/// Reads what Homebrew has installed from its prefix: `readdir` and `readlink`, never `brew`. +/// +/// Installed means what `brew list ` means: at least one non-empty version directory. An +/// interrupted uninstall leaves an empty `Cellar/` behind, and a cask's `.metadata` +/// outlives its uninstall. Neither counts. +enum BrewScan { + /// Every formula in `/Cellar` and cask in `/Caskroom`. + /// + /// A missing `Cellar` or `Caskroom` reads as empty. Below them, dot-names and anything that + /// isn't a directory are skipped, and an entry that vanishes mid-scan (`brew cleanup`) is + /// not there. Any other error throws, so a hiccup never reads as "nothing installed". + static func packages(prefix: String) throws -> BrewPackages { + var packages = BrewPackages() + for (name, kegs) in try installed(in: "\(prefix)/Cellar") { + packages.formulae[name] = try linked(kegs, at: "\(prefix)/opt/\(name)") ?? current(kegs) + } + for (name, kegs) in try installed(in: "\(prefix)/Caskroom") { + packages.casks[name] = current(kegs) + } + return packages + } + + /// Each package in `root` with its non-empty version directories. Packages with none are + /// left out. + private static func installed(in root: String) throws -> [String: [String]] { + var packages: [String: [String]] = [:] + for name in try names(in: root) ?? [] where !name.hasPrefix(".") { + var kegs: [String] = [] + for version in try names(in: "\(root)/\(name)") ?? [] where !version.hasPrefix(".") { + if let contents = try names(in: "\(root)/\(name)/\(version)"), !contents.isEmpty { + kegs.append(version) + } + } + if !kegs.isEmpty { packages[name] = kegs } + } + return packages + } + + /// The keg `link` points at (`opt/`, what `brew` runs), when it's one of `kegs`. + private static func linked(_ kegs: [String], at link: String) throws -> String? { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) + let length = readlink(link, &buffer, buffer.count - 1) + guard length >= 0 else { + let error = errno + // No link, or not a link: fall back to the highest keg. + if error == ENOENT || error == ENOTDIR || error == EINVAL { return nil } + throw POSIXError(POSIXErrorCode(rawValue: error) ?? .EIO) + } + let target = String(decoding: buffer[.. String? { + let parsed = kegs.compactMap { keg in BrewVersion(keg).map { (keg, $0) } } + if let highest = parsed.max(by: { $0.1 < $1.1 }) { return highest.0 } + return kegs.sorted().first + } + + /// The names in `directory`, without `.` and `..`. `nil` when there's no directory there: + /// missing, or a file. + private static func names(in directory: String) throws -> [String]? { + guard let handle = opendir(directory) else { + let error = errno + if error == ENOENT || error == ENOTDIR { return nil } + throw POSIXError(POSIXErrorCode(rawValue: error) ?? .EIO) + } + defer { closedir(handle) } + var names: [String] = [] + while true { + errno = 0 + guard let entry = readdir(handle) else { + let error = errno + guard error == 0 else { throw POSIXError(POSIXErrorCode(rawValue: error) ?? .EIO) } + return names + } + let name = withUnsafeBytes(of: entry.pointee.d_name) { bytes in + String(decoding: bytes.prefix(Int(entry.pointee.d_namlen)), as: UTF8.self) + } + if name != ".", name != ".." { names.append(name) } + } + } +} diff --git a/Sources/Astrolabe/BuiltIn/Brew/BrewVersion.swift b/Sources/Astrolabe/BuiltIn/Brew/BrewVersion.swift new file mode 100644 index 0000000..99a8650 --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/Brew/BrewVersion.swift @@ -0,0 +1,55 @@ +/// A version as Homebrew names a Cellar/Caskroom directory. +/// +/// `SemVer` can't read these: it requires exactly three numeric parts, while kegs are named +/// `1.104.1_1` (a formula revision), `3.0`, `2024.08.2`, or `1.2.3,456` (a cask's version +/// plus build). Numeric parts are compared left to right, the shorter one padded with zeros, +/// so `1.102 == 1.102.0`. A formula revision (`_1`) breaks ties. Anything non-numeric, such +/// as a `HEAD-…` keg or a cask's `latest`, fails to parse. +struct BrewVersion: Comparable, Sendable, CustomStringConvertible { + let parts: [Int] + let revision: Int + let description: String + + init?(_ string: String) { + var base = Substring(string) + var revision = 0 + if let underscore = base.lastIndex(of: "_") { + guard let parsed = Self.number(base[base.index(after: underscore)...]) else { return nil } + revision = parsed + base = base[.. Bool { + let (l, r) = padded(lhs, rhs) + return l != r ? l.lexicographicallyPrecedes(r) : lhs.revision < rhs.revision + } + + /// Consistent with `<`: `1.102` and `1.102.0` are the same version. + static func == (lhs: Self, rhs: Self) -> Bool { + let (l, r) = padded(lhs, rhs) + return l == r && lhs.revision == rhs.revision + } + + private static func padded(_ lhs: Self, _ rhs: Self) -> ([Int], [Int]) { + let width = max(lhs.parts.count, rhs.parts.count) + return ( + lhs.parts + Array(repeating: 0, count: width - lhs.parts.count), + rhs.parts + Array(repeating: 0, count: width - rhs.parts.count) + ) + } + + /// Digits only: `Int.init` would also accept `+1` and `-1`. + private static func number(_ text: Substring) -> Int? { + guard !text.isEmpty, text.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + return Int(text) + } +} diff --git a/Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift b/Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift new file mode 100644 index 0000000..8901bca --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift @@ -0,0 +1,107 @@ +import Semaphore +import Synchronization + +/// The Mac Command Line Tools acts on, plus the state the facts and the resource share. +/// +/// Task-local, so tests bind their own: the engine's run task, its fact loops and its operations +/// all inherit it. +final class CommandLineToolsContext: Sendable { + @TaskLocal static var current = CommandLineToolsContext(system: SoftwareUpdateCLT()) + + let system: any CommandLineToolsSystem + /// Serializes everything CLT does: both facts' reads and the whole of a rollout's install. + /// Homebrew's rollout waits on it too, through `CommandLineToolsReads.installed()`. + let lock = AsyncSemaphore(value: 1) + /// An install holds the lock and has started changing the Mac. `ensure` then waits for it + /// rather than trusting a receipt that can show before all of CLT's files are there. + let installing = Mutex(false) + /// `latestAvailable`'s last catalog check. In memory: each launch starts over. + let catalog = Mutex(CatalogCheck()) + + init(system: any CommandLineToolsSystem) { + self.system = system + } +} + +/// Installs Command Line Tools through `softwareupdate`, headlessly. +enum CLTInstaller { + /// Makes sure CLT is installed at `minimum` or newer; with `nil`, installs the newest Apple + /// offers if CLT is missing. + /// + /// Checks the Mac first, without the lock: when CLT is already there, it never waits on a + /// catalog listing. Everything that changes the Mac happens under the lock. + static func ensure(minimum: CLTVersion?) async throws { + let context = CommandLineToolsContext.current + let system = context.system + // The flag is read after the version: a version read before an install started is whole. + if satisfies(try await system.installedVersion(), minimum), !context.installing.withLock({ $0 }) { + try await system.ensureSelected() + return + } + try await locked(context) { + if !satisfies(try await system.installedVersion(), minimum) { + context.installing.withLock { $0 = true } + defer { context.installing.withLock { $0 = false } } + try await system.forgetStaleReceipts() + let picked = try await withTrigger(context) { + let offered = try await system.list() + guard let product = offered + .filter({ product in minimum.map { product.version >= $0 } ?? true }) + .max(by: { $0.version < $1.version }) + else { throw CLTError.notOffered(minimum: minimum, offered: offered.map(\.version)) } + try Task.checkCancellation() + try await system.install(product) + return product + } + let expected = minimum ?? picked.version + let installed = try await system.installedVersion() + guard satisfies(installed, expected) else { + throw CLTError.didNotTake(expected: expected, installed: installed) + } + } + try await system.ensureSelected() + } + } + + /// Runs `body` holding the context's lock. Waiting is cancellable. + static func locked(_ context: CommandLineToolsContext, _ body: () async throws -> T) async throws -> T { + try await context.lock.waitUnlessCancelled() + defer { context.lock.signal() } + return try await body() + } + + /// Runs `body` with the trigger file in place. `softwareupdate` lists and installs CLT only + /// while it exists, and a leftover file shows CLT in System Settings. Call while `locked`. + static func withTrigger(_ context: CommandLineToolsContext, _ body: () async throws -> T) async throws -> T { + try context.system.placeTrigger() + defer { context.system.removeTrigger() } + return try await body() + } + + /// `installed` is there, and at `minimum` or newer when there is one. + static func satisfies(_ installed: CLTVersion?, _ minimum: CLTVersion?) -> Bool { + guard let installed else { return false } + return minimum.map { installed >= $0 } ?? true + } +} + +enum CLTError: Error, Sendable, CustomStringConvertible { + /// The catalog offers no CLT at `minimum` or newer. Often: offline, or an MDM deferral. + case notOffered(minimum: CLTVersion?, offered: [CLTVersion]) + /// `softwareupdate` succeeded but the receipt or `usr/bin/git` doesn't show it. + case didNotTake(expected: CLTVersion, installed: CLTVersion?) + /// `pkgutil --pkg-info` succeeded without a version line Astrolabe can read. + case unreadableReceipt(output: String) + + var description: String { + switch self { + case .notOffered(let minimum, let offered): + "softwareupdate offers no Command Line Tools\(minimum.map { " at \($0) or newer" } ?? "")" + + (offered.isEmpty ? "" : " (offered: \(offered.map(\.description).joined(separator: ", ")))") + case .didNotTake(let expected, let installed): + "softwareupdate installed Command Line Tools, but the Mac shows \(installed?.description ?? "none"), not \(expected) or newer" + case .unreadableReceipt(let output): + "can't read the Command Line Tools receipt: \(output)" + } + } +} diff --git a/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineTools.swift b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineTools.swift new file mode 100644 index 0000000..c11610e --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineTools.swift @@ -0,0 +1,74 @@ +/// Keeps the Xcode Command Line Tools installed, headlessly, through `softwareupdate`. +/// +/// ```swift +/// var body: some Component { +/// CommandLineTools() // install if missing +/// CommandLineTools(version: .latest) // …and keep it at the newest Apple offers +/// } +/// ``` +/// +/// Installs the newest CLT Apple offers this macOS, and installs it again if someone deletes it: +/// `installed` is read every minute. Never downgrades, never uninstalls: dropping it from +/// `body` leaves CLT in place. +public struct CommandLineTools: Component { + public enum Version: Sendable, Equatable { + /// Any installed version. Installs the newest if missing, never updates. The default. + case installed + /// Also updates when Apple ships a newer CLT for this macOS (checked every 6 hours) or + /// after a macOS upgrade. + case latest + } + + let version: Version + @Fact(\.commandLineTools.latestAvailable) private var latestAvailable + + public init(version: Version = .installed) { + self.version = version + } + + public var body: some Component { + CommandLineToolsInstall(minimum: minimum) + } + + /// Only `.latest` reads `latestAvailable`, so only it starts the catalog checks. Both read + /// `installed`, through the resource's monitor: the first pass waits for that read, which is + /// fast and never contacts Apple. + private var minimum: CLTVersion? { + switch version { + case .installed: nil + case .latest: latestAvailable + } + } +} + +/// Keeps the Xcode Command Line Tools at `minimum` or newer. Declared by `CommandLineTools`. +@Resource +struct CommandLineToolsInstall { + static let kind = "astrolabe.commandLineTools" + /// `nil` under `.installed`, and under `.latest` before the newest is known: any installed + /// version will do, and a missing CLT gets the newest Apple offers. + let minimum: CLTVersion? + /// A deletion reruns `rollout`, which installs CLT again. An update changes nothing. + @Monitor(\.commandLineTools.isInstalled) var isInstalled + + /// One per Mac. + var id: String { "commandLineTools" } + + static func compute(_ resource: Self) -> String { + Hash.of(kind, resource.minimum?.description) + } + + @concurrent static func rollout(_ resource: Self) async throws { + try await CLTInstaller.ensure(minimum: resource.minimum) + } + + /// Never uninstalls: `brew` and every build on the Mac need CLT, and nothing would put it back. + @concurrent static func remove(_ resource: Self) async throws {} + + /// Failures are mostly network (catalog unreachable), and each attempt lists the catalog. + static var retryPolicy: RetryPolicy { .retry(Backoff(initial: .seconds(120), max: .seconds(3600))) } + + /// Waiting out another holder of the lock (e.g. a catalog check), plus a listing and an + /// install of its own. + static var timeout: Duration { .seconds(3 * 3600) } +} diff --git a/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineToolsFact.swift b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineToolsFact.swift new file mode 100644 index 0000000..022fb1b --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineToolsFact.swift @@ -0,0 +1,94 @@ +extension Facts { + /// Facts about the Xcode Command Line Tools. Reads nothing by itself. + public var commandLineTools: CommandLineToolsFacts { CommandLineToolsFacts(facts: self) } +} + +/// What's installed, and the latest Command Line Tools available to this Mac. +/// +/// Both read under the same lock as `CommandLineTools`' rollout, so neither ever reports a +/// half-finished install. Each waits out an install in progress, so the timeout outlasts the +/// longest lock hold: a listing plus an install. +public struct CommandLineToolsFacts: FactNamespace { + public let facts: Facts + + /// The installed CLT version, or `nil` when it isn't installed (no receipt, or `usr/bin/git` + /// missing). + /// + /// Hash it into a resource that must redo work after a CLT update. It reads `nil` whenever + /// CLT is missing, such as after someone deletes it, and anything declared behind + /// `if installed != nil` is then removed: gate only resources whose `remove` does nothing. + /// To repair a resource after a deletion, `@Monitor` `isInstalled` instead. + /// + /// Read every minute: a `stat` and one `pkgutil`. A read that comes due during an install + /// waits it out, so the value lands as soon as the install ends, and whatever waits on CLT + /// (`Homebrew`) goes ahead then. + @FactEntry(schedule: .every(.seconds(60)), timeout: .seconds(80 * 60), read: { + try await CommandLineToolsReads.installed() + }) + public var installed: CLTVersion? + + /// Whether CLT is on the Mac. An update doesn't change it; a deletion does. Tracked through + /// `installed`. + /// + /// `CommandLineTools` monitors it, so a deleted CLT is installed again once a read sees it gone. + public var isInstalled: Bool { installed != nil } + + /// The latest CLT available to this Mac: the newest non-beta CLT Apple's catalog offers it, + /// or the installed version when that's newer. `softwareupdate` stops listing CLT once it's + /// current, so "available" has to count what's installed. `nil` when neither is known yet. + /// + /// Checks the catalog at most every 6 hours, and never on the first read after launch, so + /// reading it never makes the app's first pass wait on Apple. + @FactEntry(schedule: .every(.seconds(15 * 60)), timeout: .seconds(80 * 60), read: { + try await CommandLineToolsReads.latestAvailable() + }) + public var latestAvailable: CLTVersion? +} + +/// The reads behind `CommandLineToolsFacts`, on the task-local context. +enum CommandLineToolsReads { + static func installed() async throws -> CLTVersion? { + let context = CommandLineToolsContext.current + return try await CLTInstaller.locked(context) { try await context.system.installedVersion() } + } + + /// `max(installed, newest offered)`, read together under the lock. Neither half alone is + /// stable across an install: the listing empties once CLT is current, and the installed + /// version moves. Their maximum is the same before an install and after it. + static func latestAvailable() async throws -> CLTVersion? { + let context = CommandLineToolsContext.current + return try await CLTInstaller.locked(context) { + let installed = try await context.system.installedVersion() + if context.catalog.withLock({ $0.begin(at: .now) }) { + let products = try await CLTInstaller.withTrigger(context) { try await context.system.list() } + context.catalog.withLock { $0.checked(products, at: .now) } + } + return [installed, context.catalog.withLock { $0.latestOffered }].compactMap { $0 }.max() + } + } +} + +/// When `latestAvailable` checks Apple's catalog. Pure, so it's tested without a clock. +struct CatalogCheck { + static let interval: Duration = .seconds(6 * 3600) + + private var started = false + private var checkedAt: ContinuousClock.Instant? + /// The newest CLT the last check listed, or `nil` when it listed none. + private(set) var latestOffered: CLTVersion? + + /// Whether this read checks the catalog. The first read since launch never does, so the + /// app's first pass never waits on Apple. A check that failed is due again. + mutating func begin(at now: ContinuousClock.Instant) -> Bool { + guard started else { + started = true + return false + } + return checkedAt.map { now - $0 >= Self.interval } ?? true + } + + mutating func checked(_ products: [CLTProduct], at now: ContinuousClock.Instant) { + checkedAt = now + latestOffered = products.map(\.version).max() + } +} diff --git a/Sources/Astrolabe/BuiltIn/CommandLineTools/SoftwareUpdate.swift b/Sources/Astrolabe/BuiltIn/CommandLineTools/SoftwareUpdate.swift new file mode 100644 index 0000000..aa3be8e --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/SoftwareUpdate.swift @@ -0,0 +1,213 @@ +import Darwin +import Foundation + +/// A Command Line Tools version as major.minor.patch. +/// +/// The receipt's `26.4.1.0.1.1745106510`, the catalog's `26.4.1`, and `16.4` (= `16.4.0`) all +/// normalize here, so a hash never depends on where a version came from. Encoded as its +/// `description`, so `records.json` reads `"26.4.1"`. +public struct CLTVersion: Comparable, Codable, Sendable, CustomStringConvertible { + public let major: Int + public let minor: Int + public let patch: Int + + /// The first three dot-separated parts, digits only; missing parts are 0. `nil` otherwise. + public init?(_ string: String) { + let parts = string.trimmingCharacters(in: .whitespaces).split(separator: ".", omittingEmptySubsequences: false) + var numbers: [Int] = [] + for part in parts.prefix(3) { + guard !part.isEmpty, part.allSatisfy({ $0.isASCII && $0.isNumber }), let number = Int(part) else { + return nil + } + numbers.append(number) + } + while numbers.count < 3 { numbers.append(0) } + (major, minor, patch) = (numbers[0], numbers[1], numbers[2]) + } + + public var description: String { "\(major).\(minor).\(patch)" } + + public static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + guard let version = CLTVersion(string) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "not a CLT version: \(string)") + } + self = version + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} + +/// A Command Line Tools product in `softwareupdate --list`. +struct CLTProduct: Equatable, Sendable { + /// Passed verbatim to `softwareupdate --install`. + let label: String + let version: CLTVersion +} + +/// Reads `softwareupdate --list` output. +/// +/// Versions come from the `Version:` field, never the label: labels changed format on macOS 26. +/// +/// ``` +/// * Label: Command Line Tools for Xcode-16.4 ← macOS 15 +/// Title: Command Line Tools for Xcode, Version: 16.4, Size: 751282KiB, Recommended: YES, +/// * Label: Command Line Tools for Xcode 26.4-26.4.1 ← macOS 26 +/// Title: Command Line Tools for Xcode 26.4, Version: 26.4.1, Size: 920104KiB, Recommended: YES, +/// ``` +enum SoftwareUpdateListing { + /// CLT products in `softwareupdate --list` output (stdout + stderr), betas excluded. + static func commandLineTools(in listing: String) -> [CLTProduct] { + var products: [CLTProduct] = [] + var label: String? + for line in listing.split(whereSeparator: \.isNewline).map({ $0.trimmingCharacters(in: .whitespaces) }) { + if line.hasPrefix("* Label: ") { + label = String(line.dropFirst("* Label: ".count)) + } else if let current = label, line.hasPrefix("Title: ") { + label = nil + let fields = fields(line) + guard current.hasPrefix("Command Line Tools"), + !current.localizedCaseInsensitiveContains("beta"), + !(fields["Title"]?.localizedCaseInsensitiveContains("beta") ?? false), + let version = fields["Version"].flatMap(CLTVersion.init) + else { continue } + products.append(CLTProduct(label: current, version: version)) + } + } + return products + } + + /// `Title: a, Version: b, …` → `["Title": "a", "Version": "b", …]`. The first of a key wins. + private static func fields(_ line: String) -> [String: String] { + var fields: [String: String] = [:] + for field in line.split(separator: ",") { + guard let colon = field.firstIndex(of: ":") else { continue } + let key = field[.. CLTVersion? + + /// When a CLTools receipt exists but `usr/bin/git` doesn't (deleted by hand), runs + /// `pkgutil --forget` on each `com.apple.pkg.CLTools_*` receipt so softwareupdate offers CLT + /// again. Otherwise does nothing. + func forgetStaleReceipts() async throws + + /// Creates `/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress`. + func placeTrigger() throws + + /// Removes the trigger file, if it's there. + func removeTrigger() + + /// `softwareupdate --list`, parsed. Throws on a non-zero exit. + func list() async throws -> [CLTProduct] + + /// `softwareupdate --install