This document describes the architecture and patterns used for implementing commands in fxios. It's intended for contributors adding new commands or modifying existing ones.
fxios uses Apple's Swift ArgumentParser framework for command-line parsing. The main entry point is Sources/fxios/fxios.swift, which defines the root Fxios command and registers all subcommands.
Simple commands have no subcommands and are implemented in a single file.
Examples: Doctor, Bootstrap, Clean, Setup, Telemetry, Version
struct Doctor: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "doctor",
abstract: "Check your development environment for required tools and configuration."
)
mutating func run() throws {
// Implementation
}
}File location: Sources/fxios/Commands/Doctor.swift
Commands with subcommands use a parent struct that defines the subcommand hierarchy. The parent typically has no run() method of its own.
Examples: Lint, L10n, Nimbus
struct Lint: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "lint",
abstract: "Run SwiftLint on the codebase.",
discussion: """
By default, lints the entire codebase. ...
""",
subcommands: [Run.self, Fix.self, Info.self],
defaultSubcommand: nil
)
}Subcommands are implemented as extensions or separate structs:
extension Lint {
struct Run: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run SwiftLint to check for violations (default)."
)
mutating func run() throws {
// Implementation
}
}
}File organization:
Commands/
└── Lint/
├── Lint.swift # Parent command
├── LintRun.swift # 'run' subcommand
├── LintFix.swift # 'fix' subcommand
├── LintInfo.swift # 'info' subcommand
└── LintHelpers.swift # Shared utilities for lint commands
Some subcommands are reused across multiple parent commands. These are defined in CommandHelpers.swift.
Example: ListSims is used by Build, Run, and Test:
struct ListSims: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list-sims",
abstract: "List available simulators and their shorthand codes."
)
func run() throws {
try CommandHelpers.printSimulatorList()
}
}Parent commands include it in their subcommands array:
static let configuration = CommandConfiguration(
commandName: "build",
subcommands: [ListSims.self],
// ...
)Most commands follow this pattern in their run() method:
mutating func run() throws {
// 1. Validate repository context (if needed)
let repo = try RepoDetector.requireValidRepo()
// 2. Check required tools
try ToolChecker.requireXcodebuild()
try ToolChecker.requireSimctl()
// 3. Resolve options from flags or config defaults
let product = CommandHelpers.resolveProduct(explicit: product, config: repo.config)
let simulator = try CommandHelpers.resolveSimulator(shorthand: sim, osVersion: os)
// 4. Handle --expose flag (print commands instead of running)
if expose {
printExposedCommands(...)
return
}
// 5. Announce command start
Herald.declare("Building \(product.scheme)...", isNewCommand: true)
// 6. Perform the work
try performBuild(...)
// 7. Announce completion
Herald.declare("Build succeeded!", asConclusion: true)
}-
Repository validation: Most commands that operate on firefox-ios call
RepoDetector.requireValidRepo()first. This validates the.fxios.yamlconfig file exists and returns the repo root and merged configuration. -
Tool checking: Use
ToolChecker.require___()methods to validate required tools are available before attempting to use them. -
Option resolution: Use
CommandHelpersto resolve options that may come from command-line flags or config defaults. -
Herald for output: All user-facing output goes through
Herald.declare()for consistent formatting. UseisNewCommand: trueat the start andasConclusion: trueat the end.
| Flag | Purpose | Implementation |
|---|---|---|
--expose |
Print shell commands instead of running them | Use Herald.raw() with CommandHelpers.formatCommand() |
--quiet / -q |
Minimize output (errors and summary only) | Check flag before Herald.declare() calls |
--debug |
Enable detailed logging | Handled globally in fxios.swift |
Commands that operate on Firefox/Focus/Klar typically include:
@Option(name: [.short, .long], help: "Product to build")
var product: BuildProduct?Resolution uses config defaults:
let buildProduct = CommandHelpers.resolveProduct(explicit: product, config: repo.config)Commands that run on simulators include:
@Option(name: .long, help: "Simulator shorthand or name (e.g., 17pro, \"iPhone 17 Pro\").")
var sim: String?
@Option(name: .long, help: "iOS version for simulator (default: latest).")
var os: String?Resolution handles shorthands and defaults:
let simulator = try CommandHelpers.resolveSimulator(shorthand: sim, osVersion: os)Each command or command group defines its own error enum:
enum BuildError: Error, CustomStringConvertible {
case projectNotFound(String)
case buildFailed(exitCode: Int32)
var description: String {
switch self {
case .projectNotFound(let path):
return "Project not found at \(path). Run 'fxios setup' first."
case .buildFailed(let exitCode):
return "Build failed with exit code \(exitCode)."
}
}
}-
Wrap underlying errors: When catching and re-throwing, include context:
catch let error as ShellRunnerError { if case .commandFailed(_, let exitCode) = error { throw BuildError.buildFailed(exitCode: exitCode) } throw error }
-
Never silently swallow errors: Either report via Herald or re-throw.
-
Use Logger for debug details:
Logger.error("Command failed", error: error)
See ERROR_HANDLING.md for complete guidelines.
The --expose flag prints the underlying shell commands instead of running them. This helps users understand what fxios does and allows them to run commands manually.
@Flag(name: .long, help: "Print the xcodebuild command instead of running it.")
var expose = false
// In run():
if expose {
printExposedCommands(...)
return
}
// Implementation:
private func printExposedCommands(...) {
Herald.raw("# Resolve Swift Package dependencies")
Herald.raw(CommandHelpers.formatCommand("xcodebuild", arguments: resolveArgs))
Herald.raw("")
Herald.raw("# Build \(product.scheme)")
Herald.raw(CommandHelpers.formatCommand("xcodebuild", arguments: buildArgs))
}Use Herald.raw() (not Herald.declare()) for exposed commands to avoid prefix formatting.
Validates the current directory is within a firefox-ios repository and loads configuration:
let repo = try RepoDetector.requireValidRepo()
// repo.root - URL to repository root
// repo.config - MergedConfig with defaults appliedValidates required tools are available:
try ToolChecker.requireGit()
try ToolChecker.requireNode()
try ToolChecker.requireNpm()
try ToolChecker.requireXcodebuild()
try ToolChecker.requireSimctl()
try ToolChecker.requireSwiftlint() // For optional tools, check availability insteadShared utilities for command implementations:
formatCommand(_:arguments:)- Format command for--exposeoutputprintSimulatorList()- Display available simulatorsresolveSimulator(shorthand:osVersion:)- Parse simulator selectionresolveProduct(explicit:config:)- Resolve product from flag or configresolvePackages(projectPath:quiet:)- Run SPM resolutionrunXcodebuild(arguments:quiet:errorTransform:)- Execute xcodebuildbuildXcodebuildArgs(...)- Build xcodebuild argument arrays
Formatted output handling. See the README for complete documentation.
Herald.declare("Starting build...", isNewCommand: true)
Herald.declare("Compiling module A")
Herald.declare("Warning: something", asError: true)
Herald.declare("Build complete!", asConclusion: true)
Herald.raw("unformatted output") // For --exposeExecute shell commands:
// Stream output to terminal
try ShellRunner.run("xcodebuild", arguments: args, workingDirectory: repoRoot)
// Capture output
let output = try ShellRunner.runAndCapture("git", arguments: ["status"])Debug logging (enabled via --debug):
Logger.debug("Processing file: \(path)")
Logger.error("Command failed", error: error)For a simple command, create Sources/fxios/Commands/MyCommand.swift:
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/
import ArgumentParser
import Foundation
struct MyCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "mycommand",
abstract: "Short description of what it does.",
discussion: """
Longer description with usage examples and details.
"""
)
// Define options and flags
@Flag(name: [.short, .long], help: "Minimize output.")
var quiet = false
@Flag(name: .long, help: "Print commands instead of running them.")
var expose = false
mutating func run() throws {
let repo = try RepoDetector.requireValidRepo()
if expose {
Herald.raw("# Command that would run")
Herald.raw("some-tool --flag")
return
}
Herald.declare("Running my command...", isNewCommand: true)
// Do work here
Herald.declare("Complete!", asConclusion: true)
}
}Add your command to the subcommands array in Sources/fxios/fxios.swift:
subcommands: [
Bootstrap.self,
Build.self,
// ...
MyCommand.self, // Add here (alphabetically)
// ...
]Create Tests/fxiosTests/MyCommandTests.swift:
import Testing
@testable import fxios
@Suite("MyCommand Tests")
struct MyCommandTests {
@Test("Command has correct configuration")
func configuration() {
#expect(MyCommand.configuration.commandName == "mycommand")
#expect(MyCommand.configuration.abstract.contains("Short description"))
}
@Test("Handles valid input")
func validInput() throws {
// Test implementation
}
}Run tests with swift test --no-parallel.
Add an entry to the "Currently Supported Commands" table in the README:
| `fxios mycommand` | Short description of what it does |Add a detailed section if the command has significant options or behavior to explain.