From 5877bf65193e6af41eb5f9a7859846bf18d0eff8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:59:12 +0200 Subject: [PATCH] feat(updater): forward-compat HermesUpdaterCommandBuilder for hermes update --yes (WS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function helper that builds argv arrays for `hermes update`, gated on `HermesCapabilities`. Pre-v0.12 → bare `update`; v0.12+ honors `--check`; v0.13+ honors `--yes` for unattended runs. No in-app "Update Hermes" affordance ships in v2.7.5 — Sparkle handles Scarf-self-update and `hermes update` is invoked by users in their terminal. This is forward-compat plumbing so the eventual UI surface shares flag selection across Mac / iOS / remote without re-deriving from scratch. Test matrix in `M0eUpdaterTests` covers all six combinations (pre-v0.12, v0.12 ± unattended ± check, v0.13 ± unattended ± check) plus an empty-capabilities fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HermesUpdaterCommandBuilder.swift | 34 ++++++++ .../ScarfCoreTests/M0eUpdaterTests.swift | 87 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift new file mode 100644 index 0000000..fde28ee --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Pure helpers that build argv arrays for `hermes update` invocations. +/// +/// Lives in ScarfCore so the eventual UI surface (Mac / iOS / remote) +/// shares flag selection. There is no in-app "Update Hermes" affordance +/// in v2.7.5 — Sparkle handles Scarf-self-update and `hermes update` is +/// invoked by users in their terminal — but capability-gated flag logic +/// is forward-compat plumbing that the future affordance will call. Each +/// helper is a `nonisolated static` pure function: no transport, no +/// MainActor, no mocking surface required. +public enum HermesUpdaterCommandBuilder { + /// Argv for an `hermes update` invocation, capability-gated. + /// + /// Pre-v0.12 hosts only had `update` (no flags). v0.12+ accepts + /// `--check` for preflight. v0.13+ accepts `--yes` / `-y` for + /// unattended runs (skips the interactive confirmation prompt). + /// Flags are silently dropped when the connected host can't honor + /// them so callers don't need to branch on capabilities themselves. + public static func updateArgv( + capabilities: HermesCapabilities, + unattended: Bool, + checkOnly: Bool + ) -> [String] { + var args: [String] = ["update"] + if checkOnly && capabilities.hasUpdateCheck { + args.append("--check") + } + if unattended && capabilities.hasUpdateNonInteractive { + args.append("--yes") + } + return args + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift new file mode 100644 index 0000000..c063028 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift @@ -0,0 +1,87 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Pure-function matrix for `HermesUpdaterCommandBuilder.updateArgv`. The +/// builder degrades flags silently when the connected host can't honor +/// them, so the "is the right flag emitted on the right version?" matrix +/// is the meaningful test surface. +@Suite struct M0eUpdaterTests { + + // MARK: - Helpers + + private func caps(_ versionLine: String?) -> HermesCapabilities { + guard let line = versionLine else { return .empty } + return HermesCapabilities.parseLine(line) + } + + // MARK: - Pre-v0.12 (no flags supported) + + @Test func preV012_returnsBareUpdateRegardlessOfFlags() { + let pre = caps("Hermes Agent v0.11.0 (2026.4.23)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: false, checkOnly: false + ) == ["update"]) + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: true, checkOnly: false + ) == ["update"]) + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: true, checkOnly: true + ) == ["update"]) + } + + @Test func unknownVersion_returnsBareUpdate() { + // No detected version means we can't guarantee any flag is + // honored; defensively emit the bare verb. + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: .empty, unattended: true, checkOnly: true + ) == ["update"]) + } + + // MARK: - v0.12 (--check supported, --yes is not) + + @Test func v012_checkOnly_emitsCheckFlag() { + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: false, checkOnly: true + ) == ["update", "--check"]) + } + + @Test func v012_unattended_dropsYesFlag() { + // v0.12 doesn't honor --yes; the helper degrades silently. + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: true, checkOnly: false + ) == ["update"]) + } + + @Test func v012_checkOnlyAndUnattended_emitsOnlyCheck() { + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: true, checkOnly: true + ) == ["update", "--check"]) + } + + // MARK: - v0.13 (full flag support) + + @Test func v013_unattended_emitsYesFlag() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: true, checkOnly: false + ) == ["update", "--yes"]) + } + + @Test func v013_checkOnlyAndUnattended_emitsBothFlags() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: true, checkOnly: true + ) == ["update", "--check", "--yes"]) + } + + @Test func v013_neither_emitsBareUpdate() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: false, checkOnly: false + ) == ["update"]) + } +}