Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions abgui/Sources/abgui/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions abgui/Sources/abgui/Backend/AbctlClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
87 changes: 87 additions & 0 deletions abgui/Sources/abgui/Models/CommandLineParser.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"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")
}
}
Loading