From cc6c64b9bd113672010d566e5b8abcd0e43bac91 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:17:39 -0700 Subject: [PATCH] feat(abgui): a console for typing abctl commands with the connection already applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GUI will always cover less of abctl than abctl covers, and the gap is where people go back to a terminal — re-deriving the connection by hand, and running in whatever directory the shell happened to be in. That second part is not hypothetical: resolving `gitops/` against the wrong working directory is the bug that made GUI writes land in a tree nobody was looking at. New Console screen. Type a command, press Return, read all three streams. It runs through the ORDINARY client seam, so a typed command is threaded with `--context`, runs in the chosen workspace, and is recorded and redacted by `RecordingRunner` exactly like one a button issued. It appears in the Command Log and the per-verb timing roll-up alongside everything else, because as far as the app is concerned there is no difference. `runConsole` deliberately does NOT map the exit code to an error the way every other client method does: a non-zero exit is a result to render, not a failure to swallow. `diff --exit-on-diff` returning 3 is drift, not breakage, and the console colours it accordingly. Safety, in the order it matters: - No `--yes` is ever added. What you type is what runs. - A write verb typed WITHOUT `--yes` is flagged before you press Run, because abctl will ask on stdin, the console gives it none, and the command aborts having changed nothing. Safe, but baffling if you expected it to run. - A write verb typed WITH `--yes` gets abgui's own confirmation naming the command and the context. A typed `--yes` is the only route in this app to a tenant write that no button asked about, so it does not get to be the one that skips asking. - `sync` without `--apply` is a dry run and is not treated as a write — confirming a dry run would teach people to click through the dialog. `CommandLineParser` handles quotes and backslash escapes because no shell is involved: a config named `Corp WiFi.mobileconfig` must arrive as one argument. It is deliberately not a shell — no pipes, redirection or variable expansion — since those would be promises with nothing behind them, and a `$TOKEN` that silently expanded would put a secret on argv, which this app never does. Go untouched and green. 5 new Swift tests cover tokenizing, the dropped leading `abctl`, an explicitly quoted empty argument, and both halves of write detection (including that a bare `sync` is not a write). Co-Authored-By: Claude Opus 5 --- abgui/Sources/abgui/AppModel.swift | 72 ++++++ abgui/Sources/abgui/Backend/AbctlClient.swift | 15 ++ .../abgui/Models/CommandLineParser.swift | 87 +++++++ abgui/Sources/abgui/Views/ConsoleView.swift | 236 ++++++++++++++++++ abgui/Sources/abgui/Views/RootView.swift | 7 +- abgui/Tests/abguiTests/ContractTests.swift | 59 +++++ 6 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 abgui/Sources/abgui/Models/CommandLineParser.swift create mode 100644 abgui/Sources/abgui/Views/ConsoleView.swift 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([])) + } +}