diff --git a/abgui/Sources/abgui/AppModel.swift b/abgui/Sources/abgui/AppModel.swift index 577d68b..589da24 100644 --- a/abgui/Sources/abgui/AppModel.swift +++ b/abgui/Sources/abgui/AppModel.swift @@ -1060,6 +1060,78 @@ final class AppModel { return ok } + // MARK: the console — type an abctl command, credentials already threaded + // + // abgui is a facade over a CLI, and the facade will always be narrower than the tool. The + // console is the escape hatch: anything abctl can do, without leaving the app and without + // re-deriving the connection by hand. It runs through the ordinary client, so a typed + // command carries the same `--context` and the same workspace cwd as the buttons — which is + // precisely the thing that was wrong when GUI writes went to the wrong tree. + + /// One console invocation and everything it produced. + struct ConsoleEntry: Identifiable, Equatable { + let id = UUID() + /// The argv actually run, REDACTED — this is `CommandRecord`'s form, so an entry can be + /// rendered, copied or logged without a secret riding along. + let argv: [String] + var stdout = "" + var stderr = "" + var exitCode: Int32 = 0 + var duration: TimeInterval = 0 + var failedToStart: String? + + var commandLine: String { CommandFormatter.line(argv) } + /// Exit 3 is abctl's "changes pending" — a normal answer from `diff --exit-on-diff`, not + /// a failure, and colouring it red would teach people to ignore red. + var isFailure: Bool { + if failedToStart != nil { return true } + return exitCode != 0 && exitCode != 3 + } + } + + var consoleEntries: [ConsoleEntry] = [] + var isRunningConsole = false + + /// Commands typed this session, newest last — for up/down recall in the input field. + var consoleHistory: [String] = [] + + /// Run a typed command line. Returns false when there was nothing to run. + @discardableResult + func runConsole(_ line: String) async -> Bool { + let argv = CommandLineParser.tokenize(line) + guard !argv.isEmpty else { return false } + guard let client = makeClient() else { + lastWriteError = "abctl was not found in the app bundle." + return false + } + let trimmed = line.trimmingCharacters(in: .whitespaces) + if consoleHistory.last != trimmed { consoleHistory.append(trimmed) } + + isRunningConsole = true + defer { isRunningConsole = false } + + var entry = ConsoleEntry(argv: CommandFormatter.redact(argv)) + let started = Date() + do { + let result = try await client.runConsole(argv) + entry.stdout = String(decoding: result.stdout, as: UTF8.self) + entry.stderr = result.stderr + entry.exitCode = result.code + } catch is CancellationError { + entry.failedToStart = "Cancelled." + } catch { + // A spawn failure or abgui's own watchdog — abctl never ran, or never finished, so + // there is no exit code to report and saying "exit 0" would be a lie. + entry.failedToStart = error.localizedDescription + } + entry.duration = Date().timeIntervalSince(started) + consoleEntries.append(entry) + if consoleEntries.count > 100 { consoleEntries.removeFirst(consoleEntries.count - 100) } + return true + } + + func clearConsole() { consoleEntries = [] } + /// Shared write wrapper: toggles isWriting, clears/sets lastWriteError, returns success. private func runWrite(_ body: (AbctlClient) async throws -> Void) async -> Bool { guard let client = makeClient() else { diff --git a/abgui/Sources/abgui/Backend/AbctlClient.swift b/abgui/Sources/abgui/Backend/AbctlClient.swift index 5079ee2..fc77281 100644 --- a/abgui/Sources/abgui/Backend/AbctlClient.swift +++ b/abgui/Sources/abgui/Backend/AbctlClient.swift @@ -293,6 +293,21 @@ struct AbctlClient { } } + /// Run an operator-typed command and hand back all three streams verbatim. + /// + /// It goes through the SAME seam as every button: `argv(_:)` appends `--context`, the run + /// happens in the workspace, and `RecordingRunner` records and redacts it — so a typed + /// command reaches the same tenant, resolves the same `gitops/` tree, and appears in the + /// Command Log exactly like one abgui issued itself. Threading credentials by hand is the + /// thing this replaces; that is the whole point of the console. + /// + /// Unlike every other method here it does NOT map the exit code to an error: a non-zero exit + /// is a RESULT to display, not a failure to swallow. `abctl diff --exit-on-diff` returning 3 + /// is the plainest example — that is drift, not breakage. + func runConsole(_ base: [String], timeout: Duration = .seconds(600)) async throws -> AbctlResult { + try await runner.run(argv(base), cwd: repoRoot, stdin: nil, timeout: timeout) + } + /// The raw `.mobileconfig` XML for a config (stdout is XML, not JSON). func configurationProfile(_ id: String) async throws -> String { let result = try await runner.run(argv(["get", "configuration", id, "--profile"]), diff --git a/abgui/Sources/abgui/Models/CommandLineParser.swift b/abgui/Sources/abgui/Models/CommandLineParser.swift new file mode 100644 index 0000000..ec3c0c0 --- /dev/null +++ b/abgui/Sources/abgui/Models/CommandLineParser.swift @@ -0,0 +1,87 @@ +// Copyright 2026 Gigaion, LLC +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +/// Turns a typed command line into argv, the way a shell would. +/// +/// The console hands what you type straight to the same `AbctlRunner` seam the buttons use — no +/// shell is involved, so quoting has to be honoured HERE or a configuration named +/// `Corp WiFi.mobileconfig` arrives as two arguments and the command fails for a reason that +/// looks nothing like the cause. +enum CommandLineParser { + /// Split on whitespace, respecting single and double quotes and backslash escapes. A leading + /// `abctl` is dropped: people paste whole command lines, and the binary is implied. + /// + /// Deliberately NOT a shell: no globbing, no `$VAR`, no pipes or redirection. Those would be + /// promises this cannot keep — there is no shell behind them — and a `$TOKEN` that silently + /// expanded would put a secret on argv, which is the one thing this app never does. + static func tokenize(_ line: String) -> [String] { + var out: [String] = [] + var current = "" + var quote: Character? + var escaped = false + var started = false // distinguishes a real empty argument ("") from no argument at all + + for ch in line { + if escaped { + current.append(ch) + escaped = false + continue + } + if ch == "\\" { + escaped = true + started = true + continue + } + if let q = quote { + if ch == q { quote = nil } else { current.append(ch) } + continue + } + if ch == "\"" || ch == "'" { + quote = ch + started = true + continue + } + if ch.isWhitespace { + if started { out.append(current) } + current = "" + started = false + continue + } + current.append(ch) + started = true + } + if started { out.append(current) } + + if let first = out.first, first.lowercased() == "abctl" { out.removeFirst() } + return out + } + + /// Verbs that change the live tenant. Used only to WARN — the gate itself stays abctl's job, + /// and this list being wrong must never be what decides whether a write happens. + static let writeVerbs: Set = [ + "create", "replace", "edit", "delete", "attach", "detach", + "assign", "unassign", "apply", "sync", + ] + + /// True when the command would write to Apple Business AND carries its own approval. abgui + /// shows its own confirmation before running one of these, because a typed `--yes` is the + /// only path in the app that reaches a tenant write without a button having asked first. + static func isApprovedTenantWrite(_ argv: [String]) -> Bool { + guard let verb = argv.first?.lowercased(), writeVerbs.contains(verb) else { return false } + // `sync` only writes with --apply; a bare `sync` is a dry run. + if verb == "sync" && !argv.contains("--apply") { return false } + // `api` is excluded from writeVerbs entirely: its method lives in a flag, and abctl + // gates any non-GET itself. + return argv.contains("--yes") + } + + /// True for a write verb typed WITHOUT approval. Worth saying out loud: abctl will ask on + /// stdin, the console gives it none, and the command aborts having changed nothing. That is + /// a safe outcome but a confusing one if you expected it to run. + static func isUnapprovedWrite(_ argv: [String]) -> Bool { + guard let verb = argv.first?.lowercased(), writeVerbs.contains(verb) else { return false } + if verb == "sync" && !argv.contains("--apply") { return false } + return !argv.contains("--yes") + } +} diff --git a/abgui/Sources/abgui/Views/ConsoleView.swift b/abgui/Sources/abgui/Views/ConsoleView.swift new file mode 100644 index 0000000..0588e96 --- /dev/null +++ b/abgui/Sources/abgui/Views/ConsoleView.swift @@ -0,0 +1,236 @@ +// Copyright 2026 Gigaion, LLC +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +/// Type an abctl command; the connection is already threaded. +/// +/// The GUI will always cover less than the CLI it wraps. Rather than pretend otherwise, this is +/// the escape hatch: the full command surface, run against the SAME tenant and the SAME workspace +/// the buttons use, with `--context` appended for you. No copying a client id into a terminal, no +/// remembering which directory `gitops/` resolves against — the mismatch that made GUI writes +/// land in the wrong tree is exactly what this removes. +struct ConsoleView: View { + @Environment(AppModel.self) private var model + + @State private var input = "" + @State private var historyIndex: Int? + @State private var pendingWrite: [String]? + @FocusState private var focused: Bool + + private var argv: [String] { CommandLineParser.tokenize(input) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + explainer + Divider() + transcript + Divider() + prompt + } + .navigationTitle("Console") + .toolbar { + Button { model.clearConsole() } label: { Label("Clear", systemImage: "trash") } + .toolbarLabel("Clear this session's console output. Nothing on Apple Business or on disk is removed.") + .disabled(model.consoleEntries.isEmpty) + } + // A typed `--yes` is the one route in the app that reaches a tenant write without a + // button having asked first. It still gets asked. + .confirmationDialog("Run a command that writes to Apple Business?", + isPresented: Binding(get: { pendingWrite != nil }, + set: { if !$0 { pendingWrite = nil } }), + titleVisibility: .visible, + presenting: pendingWrite) { command in + Button("Run it", role: .destructive) { + pendingWrite = nil + submit(force: true) + } + Button("Cancel", role: .cancel) { pendingWrite = nil } + } message: { command in + Text("\(CommandFormatter.line(command))\n\nThis carries --yes, so abctl will not stop to " + + "confirm. It runs against \(model.context.isEmpty ? "your current context" : model.context).") + } + } + + private var explainer: some View { + VStack(alignment: .leading, spacing: 3) { + Text("Runs the embedded abctl with your connection already applied — `--context` is appended " + + "and the command runs in your workspace folder, so tree-relative verbs resolve the same " + + "gitops/ tree the rest of the app uses.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text("This is not a shell: no pipes, redirection or variable expansion. Quotes and backslashes work.") + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.horizontal, .top]) + .padding(.bottom, 8) + } + + /// ScrollView + VStack rather than a List: these rows wrap monospaced output at an unbounded + /// width, which is the combination that hangs a macOS List (see CommandLogView). + @ViewBuilder private var transcript: some View { + if model.consoleEntries.isEmpty { + ContentUnavailableView { + Label("Nothing run yet", systemImage: "chevron.left.forwardslash.chevron.right") + } description: { + Text("Try `get blueprints`, `status config `, or `diff --json`. " + + "Reads are unrestricted; anything that writes still needs its own --yes.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.consoleEntries) { entry in + ConsoleEntryRow(entry: entry) + Divider() + } + Color.clear.frame(height: 1).id("tail") + } + .padding(.horizontal) + } + .scrollIndicators(.visible) + .frame(maxHeight: .infinity) + .onChange(of: model.consoleEntries.count) { _, _ in + withAnimation { proxy.scrollTo("tail", anchor: .bottom) } + } + } + } + } + + private var prompt: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(verbatim: "abctl") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + TextField("get devices --filter serialNumber=C02", text: $input) + .textFieldStyle(.plain) + .font(.system(.body, design: .monospaced)) + .focused($focused) + .onSubmit { submit() } + .onKeyPress(.upArrow) { recall(-1) } + .onKeyPress(.downArrow) { recall(1) } + if model.isRunningConsole { + ProgressView().controlSize(.small) + } + Button("Run") { submit() } + .keyboardShortcut(.return, modifiers: []) + .disabled(argv.isEmpty || model.isRunningConsole) + } + if CommandLineParser.isUnapprovedWrite(argv) { + // Worth saying BEFORE they press Run: abctl asks on stdin, the console gives it + // none, so the command aborts having changed nothing. Safe, but baffling if you + // expected it to run. + Label("This writes to Apple Business, so abctl will ask to confirm — and there's no " + + "terminal here to answer. Add --yes to run it.", + systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let error = model.lastWriteError, model.consoleEntries.isEmpty { + Text(error).font(.caption).foregroundStyle(.red) + } + } + .padding() + .background(.quaternary.opacity(0.35)) + .onAppear { focused = true } + } + + private func submit(force: Bool = false) { + let command = argv + guard !command.isEmpty, !model.isRunningConsole else { return } + if !force, CommandLineParser.isApprovedTenantWrite(command) { + pendingWrite = command + return + } + let line = input + input = "" + historyIndex = nil + Task { await model.runConsole(line) } + } + + /// Shell-style recall: up walks back through what you typed, down walks forward and out. + private func recall(_ delta: Int) -> KeyPress.Result { + let history = model.consoleHistory + guard !history.isEmpty else { return .ignored } + let next: Int + switch (historyIndex, delta) { + case (nil, -1): next = history.count - 1 + case (nil, _): return .ignored + case (let current?, _): next = current + delta + } + if next < 0 { return .handled } + if next >= history.count { // walked off the end — back to an empty prompt + historyIndex = nil + input = "" + return .handled + } + historyIndex = next + input = history[next] + return .handled + } +} + +/// One command and its three streams. stdout and stderr stay visually distinct because abctl +/// puts the machine payload on one and the human narration on the other — merging them is how a +/// JSON document becomes unparseable and a hundred lines of progress become "the error". +private struct ConsoleEntryRow: View { + let entry: AppModel.ConsoleEntry + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("$ " + entry.commandLine) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Text(status) + .font(.caption2) + .foregroundStyle(entry.isFailure ? Color.red : .secondary) + .fixedSize() + CommandCopyButton(text: copyText, help: "Copy this command and its output.") + } + if let failure = entry.failedToStart { + Text(failure).font(.caption).foregroundStyle(.red).textSelection(.enabled) + } + stream(entry.stdout, tint: .primary) + stream(entry.stderr, tint: .secondary) + } + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private func stream(_ text: String, tint: Color) -> some View { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + ScrollView(.horizontal) { + Text(trimmed) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(tint) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .scrollIndicators(.visible) + } + } + + private var status: String { + if entry.failedToStart != nil { return "did not run" } + let code = entry.exitCode == 0 ? "exit 0" : "exit \(entry.exitCode)" + return "\(code) · \(DurationText.short(entry.duration))" + } + + private var copyText: String { + var parts = ["$ abctl " + entry.commandLine] + if !entry.stdout.isEmpty { parts.append(entry.stdout) } + if !entry.stderr.isEmpty { parts.append(entry.stderr) } + parts.append("(\(status))") + return parts.joined(separator: "\n") + } +} diff --git a/abgui/Sources/abgui/Views/RootView.swift b/abgui/Sources/abgui/Views/RootView.swift index 32441ff..b3298cb 100644 --- a/abgui/Sources/abgui/Views/RootView.swift +++ b/abgui/Sources/abgui/Views/RootView.swift @@ -11,7 +11,7 @@ struct RootView: View { /// A sidebar entry. (Named SidebarItem, not Section, to avoid shadowing SwiftUI.Section.) enum SidebarItem: String, CaseIterable, Identifiable, Hashable { // Overview — the stat-tile dashboard (the landing screen) - case dashboard, systemHealth, commandLog, runLogs, whatsNew + case dashboard, systemHealth, commandLog, runLogs, console, whatsNew // GitOps — write-capable case configurations, blueprints, diff, archive // Read-only — live views abgui never mutates @@ -42,6 +42,7 @@ struct RootView: View { case .systemHealth: return "System Health" case .commandLog: return "Command Log" case .runLogs: return "Logs" + case .console: return "Console" case .whatsNew: return "What’s New" case .configurations: return "Configurations" case .blueprints: return "Blueprints" @@ -60,6 +61,7 @@ struct RootView: View { case .systemHealth: return "stethoscope" case .commandLog: return "terminal" case .runLogs: return "doc.text.magnifyingglass" + case .console: return "chevron.left.forwardslash.chevron.right" case .whatsNew: return "sparkles" case .configurations: return "doc.text" case .blueprints: return "square.stack.3d.up" @@ -117,6 +119,8 @@ struct RootView: View { .tag(SidebarItem.commandLog) Label(SidebarItem.runLogs.title, systemImage: SidebarItem.runLogs.symbol) .tag(SidebarItem.runLogs) + Label(SidebarItem.console.title, systemImage: SidebarItem.console.symbol) + .tag(SidebarItem.console) Label(SidebarItem.whatsNew.title, systemImage: SidebarItem.whatsNew.symbol) .tag(SidebarItem.whatsNew) } @@ -142,6 +146,7 @@ struct RootView: View { case .systemHealth: SystemHealthView() case .commandLog: CommandLogView() case .runLogs: RunLogsView() + case .console: ConsoleView() case .whatsNew: WhatsNewView() case .configurations: ConfigurationsView() case .blueprints: BlueprintsView() diff --git a/abgui/Tests/abguiTests/ContractTests.swift b/abgui/Tests/abguiTests/ContractTests.swift index df71fce..f33293a 100644 --- a/abgui/Tests/abguiTests/ContractTests.swift +++ b/abgui/Tests/abguiTests/ContractTests.swift @@ -1447,3 +1447,62 @@ final class RunLogIndexTests: XCTestCase { XCTAssertEqual(byVerb["sync"]?.isFailure, false) } } + +// MARK: - the console's command-line parsing + +final class CommandLineParserTests: XCTestCase { + /// Quoting has to be honoured here because no shell is involved: a config named + /// "Corp WiFi.mobileconfig" must arrive as ONE argument, or the command fails for a reason + /// that looks nothing like the cause. + func testQuotingAndEscapes() { + XCTAssertEqual(CommandLineParser.tokenize("get blueprints"), ["get", "blueprints"]) + XCTAssertEqual(CommandLineParser.tokenize(" get blueprints "), ["get", "blueprints"]) + XCTAssertEqual( + CommandLineParser.tokenize(#"adopt config "Corp WiFi.mobileconfig" --blueprint 'Default MacOS Group'"#), + ["adopt", "config", "Corp WiFi.mobileconfig", "--blueprint", "Default MacOS Group"]) + XCTAssertEqual(CommandLineParser.tokenize(#"get config Corp\ WiFi"#), ["get", "config", "Corp WiFi"]) + XCTAssertEqual(CommandLineParser.tokenize(""), []) + XCTAssertEqual(CommandLineParser.tokenize(" "), []) + } + + /// People paste whole command lines out of the README and the Command Log; the binary is + /// implied, so a leading `abctl` must not become a bogus first argument. + func testLeadingBinaryIsDropped() { + XCTAssertEqual(CommandLineParser.tokenize("abctl get devices"), ["get", "devices"]) + XCTAssertEqual(CommandLineParser.tokenize("ABCTL get devices"), ["get", "devices"]) + // ...but only as the FIRST token — an argument that happens to be "abctl" survives. + XCTAssertEqual(CommandLineParser.tokenize("get config abctl"), ["get", "config", "abctl"]) + } + + /// An explicitly quoted empty string is a real argument and must survive; whitespace alone + /// must not manufacture one. + func testExplicitEmptyArgumentSurvives() { + XCTAssertEqual(CommandLineParser.tokenize(#"context set name --api-base """#), + ["context", "set", "name", "--api-base", ""]) + } + + /// Drives abgui's own confirmation. It exists to catch a typed `--yes`, which is the only + /// route in the app to a tenant write that no button asked about. + func testApprovedTenantWriteDetection() { + XCTAssertTrue(CommandLineParser.isApprovedTenantWrite(["delete", "config", "X", "--yes"])) + XCTAssertTrue(CommandLineParser.isApprovedTenantWrite(["sync", "--apply", "--yes"])) + XCTAssertTrue(CommandLineParser.isApprovedTenantWrite(["assign", "--server", "S", "C02", "--yes"])) + + // A bare sync is a DRY RUN — confirming it would train people to click through. + XCTAssertFalse(CommandLineParser.isApprovedTenantWrite(["sync", "--yes"])) + XCTAssertFalse(CommandLineParser.isApprovedTenantWrite(["diff", "--json"])) + XCTAssertFalse(CommandLineParser.isApprovedTenantWrite(["get", "devices"])) + // adopt writes local files only. + XCTAssertFalse(CommandLineParser.isApprovedTenantWrite(["adopt", "config", "X", "--blueprint", "B"])) + } + + /// The other half: a write typed WITHOUT --yes aborts, because abctl asks on stdin and the + /// console gives it none. Safe, but the user should be told before pressing Run. + func testUnapprovedWriteIsFlagged() { + XCTAssertTrue(CommandLineParser.isUnapprovedWrite(["delete", "config", "X"])) + XCTAssertTrue(CommandLineParser.isUnapprovedWrite(["sync", "--apply"])) + XCTAssertFalse(CommandLineParser.isUnapprovedWrite(["delete", "config", "X", "--yes"])) + XCTAssertFalse(CommandLineParser.isUnapprovedWrite(["get", "blueprints"])) + XCTAssertFalse(CommandLineParser.isUnapprovedWrite([])) + } +}