From 0491683ae84921c782ad86bd90e3f0059020ae70 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 13 Oct 2026 12:10:12 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(engine):=20@Resource=20=E2=80=94=20fin?= =?UTF-8?q?d=20resource=20types=20at=20launch=20(ENG-3734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps listed every resource type in Astrolabe.resourceTypes so records could be decoded after a restart. A type left off the list failed silently: once body stopped declaring it, its record stayed undecodable and the resource was never removed. @Resource replaces the list, the way Swift Testing finds @Test: @Resource struct StagingFile { … } The macro adds the Resource conformance and a record, a @section / @used constant in __DATA_CONST,__astrolabe_res. At launch the daemon reads that section in every loaded image and seeds the registry with every linked type. - Resource requires the record. An unavailable default turns a conformance written without the macro into an error that says to add @Resource. - ResourceMacro (member + extension) rejects generic types and types nested in a generic type or function: a record names one concrete type. - Engine takes resourceTypes: RunEngine passes the linked types, tests pass their own. The test binary links ClashA/ClashB, which share a kind on purpose. - Astrolabe.resourceTypes is removed. It never shipped on main. - swift-tools-version 6.3: @section/@used are SE-0492, Swift 6.3. - Tests: the generic FakeResource

becomes four concrete @Resource fakes over a FakeResource protocol, with the same kinds and JSON. ResourceMacroTests covers expansions and diagnostics, and ResourceRecordsTests covers discovery. The macro test helper moves to AssertExpansion.swift. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 11 +- Examples/StagingEngine/StagingApp.swift | 2 - Examples/StagingEngine/StagingFile.swift | 3 +- Package.swift | 2 +- README.md | 2 +- Sources/Astrolabe/Astrolabe.swift | 11 +- Sources/Astrolabe/CLI/RunEngine.swift | 1 + Sources/Astrolabe/Engine/Engine.swift | 5 +- Sources/Astrolabe/Resource/Resource.swift | 37 +++++- .../Astrolabe/Resource/ResourceRecords.swift | 46 +++++++ .../Astrolabe/Resource/ResourceRegistry.swift | 6 +- Sources/AstrolabeMacros/Plugin.swift | 2 +- Sources/AstrolabeMacros/ResourceMacro.swift | 112 ++++++++++++++++ .../AssertExpansion.swift | 28 ++++ .../FactEntryMacroTests.swift | 20 --- .../ResourceMacroTests.swift | 125 ++++++++++++++++++ .../AstrolabeProtocolTelemetryTests.swift | 5 - Tests/AstrolabeTests/DeclarationTests.swift | 10 +- .../AstrolabeTests/EngineFactEntryTests.swift | 8 -- Tests/AstrolabeTests/EngineFactTests.swift | 8 -- .../EngineIntegrationTests.swift | 10 +- .../AstrolabeTests/ResourceRecordsTests.swift | 40 ++++++ .../Support/EngineHarness.swift | 22 +-- .../AstrolabeTests/Support/FakeResource.swift | 95 +++++++------ docs/content/docs/getting-started.mdx | 2 +- docs/content/docs/index.mdx | 2 +- 26 files changed, 482 insertions(+), 133 deletions(-) create mode 100644 Sources/Astrolabe/Resource/ResourceRecords.swift create mode 100644 Sources/AstrolabeMacros/ResourceMacro.swift create mode 100644 Tests/AstrolabeMacrosTests/AssertExpansion.swift create mode 100644 Tests/AstrolabeMacrosTests/ResourceMacroTests.swift create mode 100644 Tests/AstrolabeTests/ResourceRecordsTests.swift 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/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.swift b/Package.swift index 890797a..167e419 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 diff --git a/README.md b/README.md index f7188d6..f5b6f00 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,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/CLI/RunEngine.swift b/Sources/Astrolabe/CLI/RunEngine.swift index 494edad..902cb8f 100644 --- a/Sources/Astrolabe/CLI/RunEngine.swift +++ b/Sources/Astrolabe/CLI/RunEngine.swift @@ -33,6 +33,7 @@ struct RunEngine: AsyncParsableCommand { let telemetry = App.telemetry let engine = Engine( app: App(), + resourceTypes: ResourceRecords.linkedTypes(), store: RecordStore(url: Persistence.recordsURL), mode: mode, telemetry: telemetry diff --git a/Sources/Astrolabe/Engine/Engine.swift b/Sources/Astrolabe/Engine/Engine.swift index adc6678..9c2f9a5 100644 --- a/Sources/Astrolabe/Engine/Engine.swift +++ b/Sources/Astrolabe/Engine/Engine.swift @@ -44,8 +44,11 @@ final class Engine { /// Trap in DEBUG on a duplicate id or a kind conflict. Tests that exercise them turn it off. var assertsOnConflicts = true + /// `resourceTypes` seeds the registry, so records decode at launch: the daemon passes + /// `ResourceRecords.linkedTypes()`, tests pass the types they mean to know. init( app: App, + resourceTypes: [any Resource.Type], store: RecordStore, clock: any Clock = ContinuousClock(), mode: EngineMode = .apply, @@ -61,7 +64,7 @@ final class Engine { self.wake = wake facts = FactStore(wake: wake, clock: clock, telemetry: telemetry) do { - registry = try ResourceRegistry(App.resourceTypes) + registry = try ResourceRegistry(resourceTypes) } catch { fatalError("[Astrolabe] \(error). Every Resource type needs its own kind.") } diff --git a/Sources/Astrolabe/Resource/Resource.swift b/Sources/Astrolabe/Resource/Resource.swift index 6cc34b4..ea72128 100644 --- a/Sources/Astrolabe/Resource/Resource.swift +++ b/Sources/Astrolabe/Resource/Resource.swift @@ -5,8 +5,11 @@ /// `body`. Snapshots are written to `records.json`, so never store a secret — store a reference /// and resolve it inside `rollout`. /// +/// Conform with `@Resource`, which also lets the engine find the type at launch: +/// /// ```swift -/// struct StagingFile: Resource { +/// @Resource +/// struct StagingFile { /// static let kind = "staging.file" /// let path: String /// let contents: String @@ -55,9 +58,41 @@ public protocol Resource: Codable, Sendable { /// The engine's backstop per operation. Past it the operation is cancelled and treated as a /// failure. Default: 60 minutes. static var timeout: Duration { get } + + /// Written by `@Resource`: conform with the macro, never by hand. + static var __resourceRecord: __ResourceRecord { get } } +/// Conforms the type to `Resource` and records it in the binary, so the engine finds every +/// resource type at launch. Like SwiftUI's views, nothing lists them. +/// +/// The engine needs the type to decode a record in `records.json`. Without it, a resource that +/// `body` no longer declares would never be removed after a restart: its record could not be +/// read back. +/// +/// ```swift +/// @Resource +/// struct StagingFile { +/// static let kind = "staging.file" +/// … +/// } +/// ``` +/// +/// The macro adds a `static let __resourceRecord` that the linker gathers into +/// `__DATA_CONST,__astrolabe_res`, and the conformance unless the type already declares it. +/// `Resource` requires the record, so a conformance without the macro doesn't compile: the +/// error says to add `@Resource`. +/// +/// The type can't be generic or nested in a generic context: a record names one concrete type. +@attached(member, names: named(__resourceRecord)) +@attached(extension, conformances: Resource) +public macro Resource() = #externalMacro(module: "AstrolabeMacros", type: "ResourceMacro") + extension Resource { public static var retryPolicy: RetryPolicy { .retry() } public static var timeout: Duration { .seconds(60 * 60) } + + /// Turns a conformance written without `@Resource` into an error that says so. + @available(*, unavailable, message: "conform with `@Resource`, so the engine can find the type at launch") + public static var __resourceRecord: __ResourceRecord { fatalError() } } diff --git a/Sources/Astrolabe/Resource/ResourceRecords.swift b/Sources/Astrolabe/Resource/ResourceRecords.swift new file mode 100644 index 0000000..da61718 --- /dev/null +++ b/Sources/Astrolabe/Resource/ResourceRecords.swift @@ -0,0 +1,46 @@ +import MachO + +/// One `@Resource` type, as the macro writes it into the binary. `type` writes the type into the +/// `any Resource.Type` its argument points at. +/// +/// Not API: only `@Resource` writes these, and only `ResourceRecords` reads them. +public typealias __ResourceRecord = ( + magic: UInt32, + type: @convention(c) (UnsafeMutableRawPointer) -> Void +) + +/// Finds every `@Resource` type linked into the process, so the engine can decode any record in +/// `records.json` at launch, whether or not `body` declares its type yet. +/// +/// `@Resource` puts a `__ResourceRecord` in `__DATA_CONST,__astrolabe_res` and marks it `@used`, +/// so neither the optimizer nor the linker strips it. The linker gathers every record in an +/// image into the one section; this walks each loaded image's. +enum ResourceRecords { + /// `ResourceMacro` writes these three as literals: keep them in sync. + static let segment = "__DATA_CONST" + static let section = "__astrolabe_res" + /// Marks a record, so stray bytes in the section are never called. + static let magic: UInt32 = 0x6173_7472 // "astr" + + /// Every `@Resource` type in the images loaded so far. + static func linkedTypes() -> [any Resource.Type] { + var types: [any Resource.Type] = [] + for image in 0..<_dyld_image_count() { + guard let header = _dyld_get_image_header(image) else { continue } + var size: UInt = 0 + let header64 = UnsafeRawPointer(header).assumingMemoryBound(to: mach_header_64.self) + guard let start = getsectiondata(header64, segment, section, &size) else { continue } + let bytes = UnsafeRawBufferPointer(start: start, count: Int(size)) + let stride = MemoryLayout<__ResourceRecord>.stride + for offset in Swift.stride(from: 0, through: bytes.count - stride, by: stride) { + let record = bytes.load(fromByteOffset: offset, as: __ResourceRecord.self) + guard record.magic == magic else { continue } + types.append(withUnsafeTemporaryAllocation(of: (any Resource.Type).self, capacity: 1) { slot in + record.type(slot.baseAddress!) + return slot.baseAddress!.move() + }) + } + } + return types + } +} diff --git a/Sources/Astrolabe/Resource/ResourceRegistry.swift b/Sources/Astrolabe/Resource/ResourceRegistry.swift index e2b8b93..a4c363e 100644 --- a/Sources/Astrolabe/Resource/ResourceRegistry.swift +++ b/Sources/Astrolabe/Resource/ResourceRegistry.swift @@ -1,9 +1,9 @@ /// Maps `kind` strings to Swift types, so snapshots in `records.json` can be decoded after a /// restart. /// -/// Filled from `Astrolabe.resourceTypes`, plus every type seen in a declaration. Two types -/// claiming the same `kind` would make records ambiguous: at startup that's a fatal error, in a -/// declaration the later type is refused. +/// Filled at launch with every `@Resource` type linked into the binary, plus every type seen in +/// a declaration. Two types claiming the same `kind` would make records ambiguous: at launch +/// that's a fatal error, in a declaration the later type is refused. struct ResourceRegistry { private var types: [String: any Resource.Type] = [:] diff --git a/Sources/AstrolabeMacros/Plugin.swift b/Sources/AstrolabeMacros/Plugin.swift index e0ef412..7a26be1 100644 --- a/Sources/AstrolabeMacros/Plugin.swift +++ b/Sources/AstrolabeMacros/Plugin.swift @@ -3,5 +3,5 @@ import SwiftSyntaxMacros @main struct AstrolabeMacrosPlugin: CompilerPlugin { - let providingMacros: [any Macro.Type] = [FactEntryMacro.self] + let providingMacros: [any Macro.Type] = [FactEntryMacro.self, ResourceMacro.self] } diff --git a/Sources/AstrolabeMacros/ResourceMacro.swift b/Sources/AstrolabeMacros/ResourceMacro.swift new file mode 100644 index 0000000..07d57bf --- /dev/null +++ b/Sources/AstrolabeMacros/ResourceMacro.swift @@ -0,0 +1,112 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// `@Resource`: conforms a type to `Resource` and records it in the binary, where the engine +/// finds it at launch: +/// +/// ```swift +/// @Resource +/// struct StagingFile { … } +/// +/// // becomes +/// +/// struct StagingFile { +/// … +/// @section("__DATA_CONST,__astrolabe_res") @used +/// public nonisolated static let __resourceRecord: __ResourceRecord = ( +/// 0x6173_7472, +/// { out in out.initializeMemory(as: (any Resource.Type).self, to: StagingFile.self) } +/// ) +/// } +/// +/// extension StagingFile: Resource {} +/// ``` +/// +/// The section, segment, and magic must match `ResourceRecords` in the library; `@section` takes +/// only literals. The record is `public` because it witnesses a requirement of a public protocol, +/// and `nonisolated` so it stays a plain constant in an actor-isolated type. +/// +/// Names are unqualified: in user code `Astrolabe.` is the `Astrolabe` protocol, not the module. +public struct ResourceMacro: MemberMacro, ExtensionMacro { + /// The record. A type that can't have one gets none; this role reports why, once. + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let name = try recordedName(declaration, context) + return [ + """ + @section("__DATA_CONST,__astrolabe_res") @used + public nonisolated static let __resourceRecord: __ResourceRecord = ( + 0x6173_7472, + { out in out.initializeMemory(as: (any Resource.Type).self, to: \(name).self) } + ) + """ + ] + } + + /// The conformance, unless the type already declares it. + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard !protocols.isEmpty, (try? recordedName(declaration, context)) != nil else { return [] } + return [try ExtensionDeclSyntax("extension \(type.trimmed): Resource {}")] + } + + /// The type's name, or `DiagnosticsError` when the declaration can't have a record. + private static func recordedName( + _ declaration: some DeclGroupSyntax, + _ context: some MacroExpansionContext + ) throws -> TokenSyntax { + guard let type = declaration.asProtocol(NamedDeclSyntax.self), + declaration.is(StructDeclSyntax.self) || declaration.is(EnumDeclSyntax.self) + || declaration.is(ClassDeclSyntax.self) || declaration.is(ActorDeclSyntax.self) + else { + throw ResourceDiagnostic.notAType.error(at: declaration.introducer) + } + if let generics = declaration.asProtocol(WithGenericParametersSyntax.self)?.genericParameterClause { + throw ResourceDiagnostic.generic.error(at: generics) + } + if context.lexicalContext.contains(where: \.hasGenericParameters) { + throw ResourceDiagnostic.inGenericContext.error(at: type.name) + } + return type.name.trimmed + } +} + +/// What `@Resource` reports when the declaration can't have a record. +enum ResourceDiagnostic: String, DiagnosticMessage { + case notAType + case generic + case inGenericContext + + var message: String { + switch self { + case .notAType: "`@Resource` applies to a struct, enum, class, or actor" + case .generic: "a `@Resource` type can't be generic: its record names one concrete type" + case .inGenericContext: "a `@Resource` type can't be nested in a generic type or function" + } + } + + var diagnosticID: MessageID { MessageID(domain: "AstrolabeMacros", id: "Resource.\(rawValue)") } + var severity: DiagnosticSeverity { .error } + + func error(at node: some SyntaxProtocol) -> DiagnosticsError { + DiagnosticsError(diagnostics: [Diagnostic(node: node, message: self)]) + } +} + +extension Syntax { + /// A type, function, initializer, or subscript that declares generic parameters. + fileprivate var hasGenericParameters: Bool { + asProtocol(WithGenericParametersSyntax.self)?.genericParameterClause != nil + } +} diff --git a/Tests/AstrolabeMacrosTests/AssertExpansion.swift b/Tests/AstrolabeMacrosTests/AssertExpansion.swift new file mode 100644 index 0000000..7bdd84e --- /dev/null +++ b/Tests/AstrolabeMacrosTests/AssertExpansion.swift @@ -0,0 +1,28 @@ +import AstrolabeMacros +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +/// Every macro in the plugin, by attribute name. `@Resource`'s extension role adds `Resource`. +private let macros = [ + "FactEntry": MacroSpec(type: FactEntryMacro.self), + "Resource": MacroSpec(type: ResourceMacro.self, conformances: ["Resource"]), +] + +/// `assertMacroExpansion`, reporting through swift-testing. +func assertExpansion( + _ source: String, + expandedSource: String, + diagnostics: [DiagnosticSpec] = [], + fixedSource: String? = nil, + sourceLocation: Testing.SourceLocation = #_sourceLocation +) { + assertMacroExpansion( + source, + expandedSource: expandedSource, + diagnostics: diagnostics, + macroSpecs: macros, + fixedSource: fixedSource, + failureHandler: { Issue.record(Comment(rawValue: $0.message), sourceLocation: sourceLocation) } + ) +} diff --git a/Tests/AstrolabeMacrosTests/FactEntryMacroTests.swift b/Tests/AstrolabeMacrosTests/FactEntryMacroTests.swift index 8e7b40d..95185e9 100644 --- a/Tests/AstrolabeMacrosTests/FactEntryMacroTests.swift +++ b/Tests/AstrolabeMacrosTests/FactEntryMacroTests.swift @@ -6,26 +6,6 @@ import SwiftSyntaxMacroExpansion import SwiftSyntaxMacrosGenericTestSupport import Testing -private let macros = ["FactEntry": MacroSpec(type: FactEntryMacro.self)] - -/// `assertMacroExpansion`, reporting through swift-testing. -private func assertExpansion( - _ source: String, - expandedSource: String, - diagnostics: [DiagnosticSpec] = [], - fixedSource: String? = nil, - sourceLocation: Testing.SourceLocation = #_sourceLocation -) { - assertMacroExpansion( - source, - expandedSource: expandedSource, - diagnostics: diagnostics, - macroSpecs: macros, - fixedSource: fixedSource, - failureHandler: { Issue.record(Comment(rawValue: $0.message), sourceLocation: sourceLocation) } - ) -} - struct FactEntryMacroTests { // MARK: - Expansions diff --git a/Tests/AstrolabeMacrosTests/ResourceMacroTests.swift b/Tests/AstrolabeMacrosTests/ResourceMacroTests.swift new file mode 100644 index 0000000..a387514 --- /dev/null +++ b/Tests/AstrolabeMacrosTests/ResourceMacroTests.swift @@ -0,0 +1,125 @@ +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +struct ResourceMacroTests { + // MARK: - Expansions + + @Test func aStructGetsARecordAndAConformance() { + assertExpansion( + """ + @Resource + struct StagingFile { + static let kind = "staging.file" + } + """, + expandedSource: """ + struct StagingFile { + static let kind = "staging.file" + + @section("__DATA_CONST,__astrolabe_res") @used + public nonisolated static let __resourceRecord: __ResourceRecord = ( + 0x6173_7472, + { out in + out.initializeMemory(as: (any Resource.Type).self, to: StagingFile.self) + } + ) + } + + extension StagingFile: Resource { + } + """ + ) + } + + @Test func aNestedTypeNamesItselfUnqualifiedFromInside() { + assertExpansion( + """ + enum Brew { + @Resource + struct Formula { + static let kind = "brew.formula" + } + } + """, + expandedSource: """ + enum Brew { + struct Formula { + static let kind = "brew.formula" + + @section("__DATA_CONST,__astrolabe_res") @used + public nonisolated static let __resourceRecord: __ResourceRecord = ( + 0x6173_7472, + { out in + out.initializeMemory(as: (any Resource.Type).self, to: Formula.self) + } + ) + } + } + + extension Brew.Formula: Resource { + } + """ + ) + } + + // MARK: - Diagnostics + + @Test func aProtocolIsRejected() { + assertExpansion( + """ + @Resource + protocol Installable {} + """, + expandedSource: """ + protocol Installable {} + """, + diagnostics: [ + DiagnosticSpec(message: "`@Resource` applies to a struct, enum, class, or actor", line: 2, column: 1) + ] + ) + } + + @Test func aGenericTypeIsRejected() { + assertExpansion( + """ + @Resource + struct Default {} + """, + expandedSource: """ + struct Default {} + """, + diagnostics: [ + DiagnosticSpec( + message: "a `@Resource` type can't be generic: its record names one concrete type", + line: 2, + column: 15 + ) + ] + ) + } + + @Test func aTypeInAGenericContextIsRejected() { + for outer in ["struct Outer", "func make()"] { + assertExpansion( + """ + \(outer) { + @Resource + struct Inner {} + } + """, + expandedSource: """ + \(outer) { + struct Inner {} + } + """, + diagnostics: [ + DiagnosticSpec( + message: "a `@Resource` type can't be nested in a generic type or function", + line: 3, + column: 12 + ) + ] + ) + } + } +} diff --git a/Tests/AstrolabeTests/AstrolabeProtocolTelemetryTests.swift b/Tests/AstrolabeTests/AstrolabeProtocolTelemetryTests.swift index caba1f4..3dcb1a5 100644 --- a/Tests/AstrolabeTests/AstrolabeProtocolTelemetryTests.swift +++ b/Tests/AstrolabeTests/AstrolabeProtocolTelemetryTests.swift @@ -11,11 +11,6 @@ private struct DefaultTelemetryApp: Astrolabe { #expect(t is NoopAstrolabeTelemetry) } -@MainActor -@Test func defaultResourceTypesAreEmpty() { - #expect(DefaultTelemetryApp.resourceTypes.isEmpty) -} - @MainActor @Test func runAttributesIncludeBackendAndMode() { let attrs = TelemetryAttributes.runAttributes(DefaultTelemetryApp.self, mode: .dryRun, records: 3) diff --git a/Tests/AstrolabeTests/DeclarationTests.swift b/Tests/AstrolabeTests/DeclarationTests.swift index 847a307..8c4356b 100644 --- a/Tests/AstrolabeTests/DeclarationTests.swift +++ b/Tests/AstrolabeTests/DeclarationTests.swift @@ -119,7 +119,8 @@ private func ids(_ component: C) -> [String] { // MARK: - Registry and snapshots -private struct ClashA: Resource { +@Resource +private struct ClashA { static let kind = "clash" var id: String { "clash" } static func compute(_ resource: Self) -> String { Hash.of("a") } @@ -127,7 +128,8 @@ private struct ClashA: Resource { @concurrent static func remove(_ resource: Self) async throws {} } -private struct ClashB: Resource { +@Resource +private struct ClashB { static let kind = "clash" var id: String { "clash" } static func compute(_ resource: Self) -> String { Hash.of("b") } @@ -213,14 +215,12 @@ private struct ClashApp: Astrolabe { var body: some Component { ClashB() } - - static var resourceTypes: [any Resource.Type] { [ClashA.self] } } @MainActor @Test func engineFreezesResourcesWhoseKindIsTaken() async throws { let directory = try TemporaryDirectory() - let h = EngineHarness(recordsURL: directory.recordsURL) + let h = EngineHarness(recordsURL: directory.recordsURL, resourceTypes: [ClashA.self]) await h.start() await h.settle() #expect(h.logs("astrolabe.declaration.kind_conflict").count >= 1) diff --git a/Tests/AstrolabeTests/EngineFactEntryTests.swift b/Tests/AstrolabeTests/EngineFactEntryTests.swift index 66fa6dc..3b83678 100644 --- a/Tests/AstrolabeTests/EngineFactEntryTests.swift +++ b/Tests/AstrolabeTests/EngineFactEntryTests.swift @@ -47,8 +47,6 @@ private struct EntryApp: Astrolabe { for resource in entry { resource } for resource in test.retry { resource } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } private struct PolledEntryApp: Astrolabe { @@ -57,8 +55,6 @@ private struct PolledEntryApp: Astrolabe { var body: some Component { for resource in polled { resource } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } private struct OptionalEntryApp: Astrolabe { @@ -67,8 +63,6 @@ private struct OptionalEntryApp: Astrolabe { var body: some Component { RetryFake("optional", optional ?? "none") } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } private struct EntryPairApp: Astrolabe { @@ -79,8 +73,6 @@ private struct EntryPairApp: Astrolabe { RetryFake("both", "1") } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } @MainActor diff --git a/Tests/AstrolabeTests/EngineFactTests.swift b/Tests/AstrolabeTests/EngineFactTests.swift index 2ca3725..062a7fe 100644 --- a/Tests/AstrolabeTests/EngineFactTests.swift +++ b/Tests/AstrolabeTests/EngineFactTests.swift @@ -59,8 +59,6 @@ private struct GateApp: Astrolabe { } for resource in test.capped { resource } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self, CappedFake.self] } } private struct PollApp: Astrolabe { @@ -71,8 +69,6 @@ private struct PollApp: Astrolabe { for resource in polled { resource } for resource in test.retry { resource } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } private struct OptionalApp: Astrolabe { @@ -81,8 +77,6 @@ private struct OptionalApp: Astrolabe { var body: some Component { RetryFake("optional", optional ?? "none") } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } private struct PairApp: Astrolabe { @@ -93,8 +87,6 @@ private struct PairApp: Astrolabe { RetryFake("both", "1") } } - - static var resourceTypes: [any Resource.Type] { [RetryFake.self] } } @MainActor diff --git a/Tests/AstrolabeTests/EngineIntegrationTests.swift b/Tests/AstrolabeTests/EngineIntegrationTests.swift index 785b9a9..c2c0da5 100644 --- a/Tests/AstrolabeTests/EngineIntegrationTests.swift +++ b/Tests/AstrolabeTests/EngineIntegrationTests.swift @@ -3,7 +3,8 @@ import Testing @testable import Astrolabe /// A real file, like the staging example's, under a test's temporary directory. -private struct TempFile: Resource, Equatable { +@Resource +private struct TempFile: Equatable { static let kind = "test.file" let path: String let contents: String @@ -57,8 +58,6 @@ private struct FileApp: Astrolabe { TempFile(path: "\(state.directory)/\(name)", contents: contents) } } - - static var resourceTypes: [any Resource.Type] { [TempFile.self] } } private func read(_ path: String) -> String? { @@ -78,7 +77,10 @@ private func writeState(_ files: [String: String], in directory: URL) throws { try FileManager.default.createDirectory(atPath: files, withIntermediateDirectories: true) try writeState(["a.txt": "1", "b.txt": "2"], in: directory.url) - let engine = Engine(app: FileApp(), store: RecordStore(url: directory.recordsURL), clock: ContinuousClock()) + let engine = Engine( + app: FileApp(), resourceTypes: [TempFile.self], store: RecordStore(url: directory.recordsURL), + clock: ContinuousClock() + ) let run = TempFilesKey.$directory.withValue(directory.url.path) { Task { await engine.run() } } diff --git a/Tests/AstrolabeTests/ResourceRecordsTests.swift b/Tests/AstrolabeTests/ResourceRecordsTests.swift new file mode 100644 index 0000000..37ab932 --- /dev/null +++ b/Tests/AstrolabeTests/ResourceRecordsTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import Astrolabe + +/// Nested and never declared or named by an engine: only its record makes it known. +private enum Namespace { + @Resource + struct Unlisted { + static let kind = "test.unlisted" + var id: String { "unlisted" } + static func compute(_ resource: Self) -> String { Hash.of(kind) } + @concurrent static func rollout(_ resource: Self) async throws {} + @concurrent static func remove(_ resource: Self) async throws {} + } +} + +@Test func everyResourceTypeInTheBinaryIsFoundOnce() { + var counts: [String: Int] = [:] + for type in ResourceRecords.linkedTypes() { + counts[type.kind, default: 0] += 1 + } + for kind in ["fake.retry", "fake.rollback", "fake.wait", "fake.capped", "test.file", "test.unlisted"] { + #expect(counts[kind] == 1, "\(kind)") + } + // `ClashA` and `ClashB` share a kind. + #expect(counts["clash"] == 2) +} + +@Test func aRegistryOverTheLinkedTypesDecodesAnyOfThem() throws { + let registry = try ResourceRegistry(ResourceRecords.linkedTypes().filter { $0.kind != "clash" }) + #expect(registry.type(for: "test.unlisted") == Namespace.Unlisted.self) + #expect(registry.type(for: "fake.retry") == RetryFake.self) +} + +/// At launch the daemon registers every linked type, so two types claiming one kind fail it +/// even if `body` never declares either. +@Test func twoLinkedTypesWithOneKindFailTheRegistry() { + #expect(throws: ResourceRegistry.Conflict.self) { + _ = try ResourceRegistry(ResourceRecords.linkedTypes()) + } +} diff --git a/Tests/AstrolabeTests/Support/EngineHarness.swift b/Tests/AstrolabeTests/Support/EngineHarness.swift index 526bef5..f25e5c4 100644 --- a/Tests/AstrolabeTests/Support/EngineHarness.swift +++ b/Tests/AstrolabeTests/Support/EngineHarness.swift @@ -13,10 +13,6 @@ struct TestApp: Astrolabe { for resource in test.wait { resource } for resource in test.capped { resource } } - - static var resourceTypes: [any Resource.Type] { - [RetryFake.self, RollbackFake.self, WaitFake.self, CappedFake.self] - } } /// A temporary directory removed when the fixture goes out of scope. @@ -35,7 +31,8 @@ struct TemporaryDirectory: ~Copyable { } /// An engine over an app (usually `TestApp`), a `FakeMachine`, `FakeFacts`, a `ManualClock`, and -/// a records file in a temporary directory. +/// a records file in a temporary directory. The engine knows `resourceTypes` at launch, where +/// the daemon would know every `@Resource` type linked into it. @MainActor final class EngineHarness { let machine = FakeMachine() @@ -45,15 +42,18 @@ final class EngineHarness { let telemetry = RecordingTelemetry() let recordsURL: URL let mode: EngineMode + let resourceTypes: [any Resource.Type] private(set) var app = App() private(set) var engine: Engine private var runTask: Task? - init(recordsURL: URL, mode: EngineMode = .apply) { + init(recordsURL: URL, mode: EngineMode = .apply, resourceTypes: [any Resource.Type] = fakeResourceTypes) { self.recordsURL = recordsURL self.mode = mode + self.resourceTypes = resourceTypes engine = Engine( - app: app, store: RecordStore(url: recordsURL), clock: clock, mode: mode, telemetry: telemetry + app: app, resourceTypes: resourceTypes, store: RecordStore(url: recordsURL), + clock: clock, mode: mode, telemetry: telemetry ) engine.assertsOnConflicts = false fakeFacts.set(TestValuesKey.self, TestValues()) @@ -100,12 +100,13 @@ final class EngineHarness { } /// Stops the engine and starts a new one — a fresh app and fact store, the same records - /// file, machine, facts on the Mac, and clock — like a restart of the daemon. + /// file, resource types, machine, facts on the Mac, and clock — like a restart of the daemon. func restart(injecting: Bool = true) async { await stop() app = App() engine = Engine( - app: app, store: RecordStore(url: recordsURL), clock: clock, mode: mode, telemetry: telemetry + app: app, resourceTypes: resourceTypes, store: RecordStore(url: recordsURL), + clock: clock, mode: mode, telemetry: telemetry ) engine.assertsOnConflicts = false await start(injecting: injecting) @@ -179,12 +180,13 @@ func withEngine( func withEngine( _: App.Type, mode: EngineMode = .apply, + resourceTypes: [any Resource.Type] = fakeResourceTypes, injecting: Bool = true, before: (EngineHarness) -> Void = { _ in }, _ body: (EngineHarness) async throws -> Void ) async throws { let directory = try TemporaryDirectory() - let harness = EngineHarness(recordsURL: directory.recordsURL, mode: mode) + let harness = EngineHarness(recordsURL: directory.recordsURL, mode: mode, resourceTypes: resourceTypes) before(harness) await harness.start(injecting: injecting) do { diff --git a/Tests/AstrolabeTests/Support/FakeResource.swift b/Tests/AstrolabeTests/Support/FakeResource.swift index bb6fb3e..04782ee 100644 --- a/Tests/AstrolabeTests/Support/FakeResource.swift +++ b/Tests/AstrolabeTests/Support/FakeResource.swift @@ -1,50 +1,18 @@ import Foundation @testable import Astrolabe -/// Picks a `FakeResource`'s retry policy and timeout. Policies are statics on the resource -/// type, so each policy under test is its own type. -protocol FakePolicy: Sendable { - static var name: String { get } - static var policy: RetryPolicy { get } - static var timeout: Duration { get } -} - -extension FakePolicy { - static var timeout: Duration { .seconds(60 * 60) } -} - -enum Retrying: FakePolicy { - static let name = "retry" - static var policy: RetryPolicy { .retry() } -} - -enum RollingBack: FakePolicy { - static let name = "rollback" - static var policy: RetryPolicy { .rollback() } -} - -enum Waiting: FakePolicy { - static let name = "wait" - static var policy: RetryPolicy { .wait } -} - -/// `.retry()` with a one-minute cap per operation. -enum Capped: FakePolicy { - static let name = "capped" - static var policy: RetryPolicy { .retry() } - static var timeout: Duration { .seconds(60) } -} - /// A resource that acts on the task-local `FakeMachine`. Its id is `fake:`; its hash -/// covers `name` and `value`. -struct FakeResource: Resource, Equatable { - static var kind: String { "fake.\(P.name)" } - let name: String - let value: String +/// covers `name` and `value`. Policies are statics on the resource type, so each policy under +/// test is its own type below. +protocol FakeResource: Resource, Equatable { + var name: String { get } + var value: String { get } + init(name: String, value: String) +} +extension FakeResource { init(_ name: String, _ value: String) { - self.name = name - self.value = value + self.init(name: name, value: value) } var id: String { "fake:\(name)" } @@ -61,9 +29,6 @@ struct FakeResource: Resource, Equatable { try await machine().perform(.remove, id: resource.id, value: resource.value) } - static var retryPolicy: RetryPolicy { P.policy } - static var timeout: Duration { P.timeout } - private static func machine() -> FakeMachine { guard let machine = FakeMachine.current else { fatalError("FakeResource used outside FakeMachine.$current.withValue") @@ -72,7 +37,41 @@ struct FakeResource: Resource, Equatable { } } -typealias RetryFake = FakeResource -typealias RollbackFake = FakeResource -typealias WaitFake = FakeResource -typealias CappedFake = FakeResource +/// `.retry()`, the default. +@Resource +struct RetryFake: FakeResource { + static let kind = "fake.retry" + let name: String + let value: String +} + +@Resource +struct RollbackFake: FakeResource { + static let kind = "fake.rollback" + let name: String + let value: String + + static var retryPolicy: RetryPolicy { .rollback() } +} + +@Resource +struct WaitFake: FakeResource { + static let kind = "fake.wait" + let name: String + let value: String + + static var retryPolicy: RetryPolicy { .wait } +} + +/// `.retry()` with a one-minute cap per operation. +@Resource +struct CappedFake: FakeResource { + static let kind = "fake.capped" + let name: String + let value: String + + static var timeout: Duration { .seconds(60) } +} + +/// Every fake, for an engine that should know them all. +let fakeResourceTypes: [any Resource.Type] = [RetryFake.self, RollbackFake.self, WaitFake.self, CappedFake.self] diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx index b5dc552..3a11739 100644 --- a/docs/content/docs/getting-started.mdx +++ b/docs/content/docs/getting-started.mdx @@ -30,7 +30,7 @@ In your `Package.swift`, add the package dependency and link the `Astrolabe` product from your executable target: ```swift -// swift-tools-version: 6.2 +// swift-tools-version: 6.3 import PackageDescription diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index e979ed9..278fde8 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -70,7 +70,7 @@ Astrolabe is a good fit when you want a Mac to remain in a known state over time Astrolabe is macOS-only: -- Swift 6.2 or newer. +- Swift 6.3 or newer. - macOS 15 or newer, as declared in `Package.swift`. - Root privileges at runtime. - launchd, `installer`, `pkgutil`, `softwareupdate`, `scutil`, and Homebrew From 3b934158f1264f3252e92cf0c0bff44c7d212be2 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 13 Oct 2026 16:07:56 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(clt):=20CommandLineTools=20=E2=80=94?= =?UTF-8?q?=20Xcode=20CLT=20as=20a=20v2=20resource=20(ENG-3739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 engine couldn't install the Xcode Command Line Tools, so it couldn't bootstrap Homebrew. CommandLineTools is the first built-in resource, installed headlessly through softwareupdate: CommandLineTools() // install if missing CommandLineTools(version: .latest) // and keep it current .installed reads no fact. .latest reads \.commandLineTools .latestAvailable, the max of the installed version and the newest non-beta CLT the catalog lists: softwareupdate stops listing CLT once it's current, so the max is the same before and after an install and is safe to hash. Its first read after launch never contacts Apple, and it checks the catalog at most every 6 hours. \.commandLineTools .installed is public for resources that must redo work after an update. - Both facts read under the lock the rollout installs under, so neither sees a half-finished install. ensure checks the Mac first without the lock, unless an install is in progress. - Versions are read from the listing's Version: field, not the label (macOS 26 changed the format), and normalize to major.minor.patch. - The rollout forgets a stale receipt, places the trigger file (removed on every path), installs the newest CLT at or above the minimum, verifies it, and selects it when xcode-select points nowhere. remove never uninstalls. - All I/O goes through CommandLineToolsSystem, bound per task through CommandLineToolsContext, so tests never run softwareupdate. - Re-adds the Semaphore dependency for the cancellable lock. - Tests: listing fixtures for macOS 15 and 26, versions, the catalog schedule, both reads, the rollout, the lock, and engine runs over a FakeCLTSystem that writes its receipt before the install finishes. Co-Authored-By: Claude Opus 5.5 --- Package.resolved | 11 +- Package.swift | 2 + README.md | 14 +- .../CommandLineTools/CLTInstaller.swift | 107 +++ .../CommandLineTools/CommandLineTools.swift | 70 ++ .../CommandLineToolsFact.swift | 82 ++ .../CommandLineTools/SoftwareUpdate.swift | 213 ++++++ .../CommandLineToolsTests.swift | 708 ++++++++++++++++++ .../AstrolabeTests/ResourceRecordsTests.swift | 2 +- .../Support/FakeCLTSystem.swift | 164 ++++ 10 files changed, 1368 insertions(+), 5 deletions(-) create mode 100644 Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift create mode 100644 Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineTools.swift create mode 100644 Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineToolsFact.swift create mode 100644 Sources/Astrolabe/BuiltIn/CommandLineTools/SoftwareUpdate.swift create mode 100644 Tests/AstrolabeTests/CommandLineToolsTests.swift create mode 100644 Tests/AstrolabeTests/Support/FakeCLTSystem.swift 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 167e419..06724d3 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] diff --git a/README.md b/README.md index f5b6f00..88080c2 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,17 @@ > 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. Never gate a +> declaration on it: a reinstall flips it, and anything behind the `if` would be removed. > > Everything below documents v1, which stays on `main`. diff --git a/Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift b/Sources/Astrolabe/BuiltIn/CommandLineTools/CLTInstaller.swift new file mode 100644 index 0000000..8157a5e --- /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 takes it too, through `CLTInstaller.ensure`. + 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. Homebrew's rollout calls `ensure(minimum: nil)`. + /// + /// 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..248457a --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineTools.swift @@ -0,0 +1,70 @@ +/// 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. 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 a fact, so only it starts the catalog checks. It needs no + /// `installed`: `latestAvailable` already counts it. + 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? + + /// 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. Homebrew's `ensure`), 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..2ac852d --- /dev/null +++ b/Sources/Astrolabe/BuiltIn/CommandLineTools/CommandLineToolsFact.swift @@ -0,0 +1,82 @@ +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. Never gate a declaration + /// on it: a reinstall flips it, and anything behind the `if` would be removed. + @FactEntry(schedule: .every(.seconds(15 * 60)), timeout: .seconds(80 * 60), read: { + try await CommandLineToolsReads.installed() + }) + public var installed: CLTVersion? + + /// 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