[object Object]

← back to Exo

EXO: add CLI flags for root install/uninstall

3a9baeb9db469975f9c760960bf0d1fcb187d2ce · 2026-02-03 22:49:47 +0000 · Jake Hillion

The macOS app required user interaction via AppleScript prompts to
install or uninstall network configuration components, making automated
deployments difficult.

Added --install and --uninstall command line flags that execute the
network setup scripts directly when running as root, bypassing GUI
prompts. Created a new main.swift entry point that parses CLI arguments
and delegates to NetworkSetupHelper's new direct execution methods.

This enables headless installation via `sudo EXO --install` for
automated deployment scenarios while preserving the existing GUI
behavior when launched normally.

Test plan:

- Deployed to a machine that didn't have the content installed. Got
  blocked on the popup and EXO never launched.
- Relaunched EXO, confirmed it still never starts because of the popup.
- Ran `sudo /Applications/EXO.app/Contents/MacOS/EXO --install`
- Launched EXO - the API started as expected.
- Ran `sudo /Applications/EXO.app/Contents/MacOS/EXO --uninstall`
- Launched EXO - got the popup.

Files touched

Diff

commit 3a9baeb9db469975f9c760960bf0d1fcb187d2ce
Author: Jake Hillion <jake@hillion.co.uk>
Date:   Tue Feb 3 22:49:47 2026 +0000

    EXO: add CLI flags for root install/uninstall
    
    The macOS app required user interaction via AppleScript prompts to
    install or uninstall network configuration components, making automated
    deployments difficult.
    
    Added --install and --uninstall command line flags that execute the
    network setup scripts directly when running as root, bypassing GUI
    prompts. Created a new main.swift entry point that parses CLI arguments
    and delegates to NetworkSetupHelper's new direct execution methods.
    
    This enables headless installation via `sudo EXO --install` for
    automated deployment scenarios while preserving the existing GUI
    behavior when launched normally.
    
    Test plan:
    
    - Deployed to a machine that didn't have the content installed. Got
      blocked on the popup and EXO never launched.
    - Relaunched EXO, confirmed it still never starts because of the popup.
    - Ran `sudo /Applications/EXO.app/Contents/MacOS/EXO --install`
    - Launched EXO - the API started as expected.
    - Ran `sudo /Applications/EXO.app/Contents/MacOS/EXO --uninstall`
    - Launched EXO - got the popup.
---
 app/EXO/EXO/EXOApp.swift                      |  1 -
 app/EXO/EXO/Services/NetworkSetupHelper.swift | 55 +++++++++++++++++
 app/EXO/EXO/main.swift                        | 85 +++++++++++++++++++++++++++
 3 files changed, 140 insertions(+), 1 deletion(-)

diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift
index 7669c408..3aff58c3 100644
--- a/app/EXO/EXO/EXOApp.swift
+++ b/app/EXO/EXO/EXOApp.swift
@@ -14,7 +14,6 @@ import SwiftUI
 import UserNotifications
 import os.log
 
-@main
 struct EXOApp: App {
     @StateObject private var controller: ExoProcessController
     @StateObject private var stateService: ClusterStateService
diff --git a/app/EXO/EXO/Services/NetworkSetupHelper.swift b/app/EXO/EXO/Services/NetworkSetupHelper.swift
index 82cb82d4..5428ee8e 100644
--- a/app/EXO/EXO/Services/NetworkSetupHelper.swift
+++ b/app/EXO/EXO/Services/NetworkSetupHelper.swift
@@ -288,6 +288,61 @@ enum NetworkSetupHelper {
         """
     }
 
+    /// Direct install without GUI (requires root).
+    /// Returns true on success, false on failure.
+    static func installDirectly() -> Bool {
+        let script = makeInstallerScript()
+        return runShellDirectly(script)
+    }
+
+    /// Direct uninstall without GUI (requires root).
+    /// Returns true on success, false on failure.
+    static func uninstallDirectly() -> Bool {
+        let script = makeUninstallScript()
+        return runShellDirectly(script)
+    }
+
+    /// Run a shell script directly via Process (no AppleScript, requires root).
+    /// Returns true on success, false on failure.
+    private static func runShellDirectly(_ script: String) -> Bool {
+        let process = Process()
+        process.executableURL = URL(fileURLWithPath: "/bin/bash")
+        process.arguments = ["-c", script]
+
+        let outputPipe = Pipe()
+        let errorPipe = Pipe()
+        process.standardOutput = outputPipe
+        process.standardError = errorPipe
+
+        do {
+            try process.run()
+            process.waitUntilExit()
+
+            let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
+            let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
+
+            if let output = String(data: outputData, encoding: .utf8), !output.isEmpty {
+                print(output)
+            }
+            if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty {
+                fputs(errorOutput, stderr)
+            }
+
+            if process.terminationStatus == 0 {
+                logger.info("Shell script completed successfully")
+                return true
+            } else {
+                logger.error("Shell script failed with exit code \(process.terminationStatus)")
+                return false
+            }
+        } catch {
+            logger.error(
+                "Failed to run shell script: \(error.localizedDescription, privacy: .public)")
+            fputs("Error: \(error.localizedDescription)\n", stderr)
+            return false
+        }
+    }
+
     private static func runShellAsAdmin(_ script: String) throws {
         let escapedScript =
             script
diff --git a/app/EXO/EXO/main.swift b/app/EXO/EXO/main.swift
new file mode 100644
index 00000000..9383981f
--- /dev/null
+++ b/app/EXO/EXO/main.swift
@@ -0,0 +1,85 @@
+//
+//  main.swift
+//  EXO
+//
+//  Created by Jake Hillion on 2026-02-03.
+//
+
+import Foundation
+
+/// Command line options for the EXO app
+enum CLICommand {
+    case install
+    case uninstall
+    case help
+    case none
+}
+
+/// Parse command line arguments to determine the CLI command
+func parseArguments() -> CLICommand {
+    let args = CommandLine.arguments
+    if args.contains("--help") || args.contains("-h") {
+        return .help
+    }
+    if args.contains("--install") {
+        return .install
+    }
+    if args.contains("--uninstall") {
+        return .uninstall
+    }
+    return .none
+}
+
+/// Print usage information
+func printUsage() {
+    let programName = (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "EXO"
+    print(
+        """
+        Usage: \(programName) [OPTIONS]
+
+        Options:
+          --install     Install EXO network configuration (requires root)
+          --uninstall   Uninstall EXO network configuration (requires root)
+          --help, -h    Show this help message
+
+        When run without options, starts the normal GUI application.
+
+        Examples:
+          sudo \(programName) --install    Install network components as root
+          sudo \(programName) --uninstall  Remove network components as root
+        """)
+}
+
+/// Check if running as root
+func isRunningAsRoot() -> Bool {
+    return getuid() == 0
+}
+
+// Main entry point
+let command = parseArguments()
+
+switch command {
+case .help:
+    printUsage()
+    exit(0)
+
+case .install:
+    if !isRunningAsRoot() {
+        fputs("Error: --install requires root privileges. Run with sudo.\n", stderr)
+        exit(1)
+    }
+    let success = NetworkSetupHelper.installDirectly()
+    exit(success ? 0 : 1)
+
+case .uninstall:
+    if !isRunningAsRoot() {
+        fputs("Error: --uninstall requires root privileges. Run with sudo.\n", stderr)
+        exit(1)
+    }
+    let success = NetworkSetupHelper.uninstallDirectly()
+    exit(success ? 0 : 1)
+
+case .none:
+    // Start normal GUI application
+    EXOApp.main()
+}

← 01b86a9e feat: add uncertainty visualization with token-level logprob  ·  back to Exo  ·  Add Qwen3-Coder-Next model cards (#1367) ffe6396c →