Pull to refresh

Building your own CLI with Swift Programming Language

Level of difficulty Easy
Reading time 5 min
Views 3.2K

Command-line interfaces (CLI) are a common way to use applications. In iOS, we usually use scripting languages like Bash or Ruby to build those CLIs and automate mundane tasks. The most popular CLI for app signing and build automation is, without a doubt, Fastlane, which was initially written in Ruby. Fastlane is a great tool, convenient and fairly easy to use, and a lot of effort came into building it.

However, there's a great chance you considered moving away from Fastlane to avoid learning Ruby and to lower the entry threshold for your developers. Setting up a Ruby environment could be quite tedious and require additional devs' expertise to write and support those scripts.

Also, Fastlane comes with a lot of dependencies itself. There are 200+ lines in Gemfile.lock describing dependencies.

And that's just a part of that list.


To counter that, we can use our currently most popular language in iOS – Swift. As a multipurpose language, Swift allows us to write not only apps, but also backend, scripts, and, specifically, command line tools.

An interesting note on Fastlane – they are adding the Swift version of their tools. It's currently in beta, and most times it's just a bridge to Ruby code.

1. Setting up the project

The hard way

The first alternative would be to create a project in Xcode and use the Command line tool template. Without any additional dependencies, we could create a Script.swift file and add code like this:

import Foundation

let arguments = CommandLine.arguments

guard 
    arguments.count == 3,
    let num1 = Int(arguments[1]), let num2 = Int(arguments[2]) 
else {    
    print("Usage: add <num1> <num2>")    
    exit(1)
}

let result = num1 + num2
print(result)

This simple example wouldn't need compiling and could be executed as a script. We only need to add a swift environment to our script like so:

#!/usr/bin/env swift

It implies you have Xcode command line tools or a separate swift runtime installed. We should also make our file executable and give it all the needed permissions to run:

  • remove .swift extension

  • run chmod u+x Script

Where u is the owner and x is the execution permission. We can consider adding a instead of u for our developer scripts.

The final script file will look like this:

#!/usr/bin/env swift

import Foundation

let arguments = CommandLine.arguments

guard 
    arguments.count == 3,
    let num1 = Int(arguments[1]), let num2 = Int(arguments[2]) 
else {    
    print("Usage: add <num1> <num2>")    
    exit(1)
}

let result = num1 + num2
print(result) 

Then we could execute it with ./Script command in our terminal.

The fun way

But if you plan to build a thorough CLI with subcommands and arguments there is a nicer way.

The first step would be the same. Create an Xcode project with a command line tool template.

Then add a Swift Argument Parser framework using your dependency manager of choice. Swift package manager would be a great option nowadays.

Swift Argument Parser is supported by Apple itself and uses all the latest Swift features like property wrappers and structured concurrency. At the same time, we are maintaining our CLI code readable and structured.

2. Writing the tools

With that dependency in place, we could write our reusable commands and subcommands in either object-oriented or protocol-oriented way. Here's my example of using CLI to test the iOS app on pull requests.

Adding app`s entry point

import Foundation
import ArgumentParser

struct Habramator: ParsableCommand {
    
    static let configuration = CommandConfiguration(
        commandName: "habramator",
        abstract: "Command line tools for your iOS project",
        subcommands: [
            CI.self,
            Dev.self
        ]
    )
} 

First, we define our app's main command. If you're building your CLI as a package, you'll need a @main attribute added to the Habramator struct. In this case, the habramator is our CLI entry point.

If your CLI doesn't need subcommands or you need a default action, override the func run() throws for that at the Habramator struct level.

Adding subcommands

Then we define our subcommands if needed. Habramator contains 2 subcommands which might look like this:

import Foundation
import ArgumentParser

struct CI: ParsableCommand {
    
    static var configuration = CommandConfiguration(
        commandName: "ci",
        abstract: "Runs on CI only",
        shouldDisplay: false,
        subcommands: [
            UnitTests.self
        ]
    )
}

struct Dev: ParsableCommand {
    
    static let configuration = CommandConfiguration(
        commandName: "dev",
        abstract: "Runs on dev machine",
        subcommands: [
            // List of subcommands
        ]
    )
} 

We intend to use CI subcommand in our continuous integration system. Subcommands could contain sensitive info usage, like API keys or passwords for production certificates. We can obtain sensitive info from the execution environment like this:

import Foundation

let environmentVariable = ProcessInfo.processInfo.environment["KEY"] 

The Dev subcommand is intended for our team. It could include such tasks as getting provisional profiles, updating our project with resources, etc.

Adding more subcommands

struct UnitTests: ParsableCommand {
  
    static let configuration = CommandConfiguration(
        commandName: "unit-tests",
        abstract: "Run Unit tests",
        shouldDisplay: false
    )

    func run() throws {
        CommandRunner.execute(command: Test.unitTests)
    }
} 

Note how we can reuse these sub subcommands both in dev and ci if needed. We used a CommandRunner entity, which is essentially our strongly typed wrapper around the shell executor:

struct CommandRunner {
    
    private static let shell = Shell()
    private init() {}
    
    static func execute(command: any Command) {
        shell.run(
            command: """
            xcodebuild test \
            -workspace \(Workspace.app) \
            -scheme \(Scheme.mainAppScheme) \
            -destination \"\(TestDestination.iPhone12iOS15)\" \
            -testPlan \(TestPlan.appUnitTests)
            """
        )
    }
}

That's a simple example of how we could shorten our CI calls from a long xcodebuild command to only habramator ci unit-tests. This way, we keep our CI pipeline yamls the same while changing the implementation of the execute method. We also use constants to define our project location, a scheme to test, a test plan, and so on.

It could all be passed into the execution environment or as command arguments if needed. To add an argument to our command we should use an @Argument property wrapper

@Argument(help: "An app scheme to test", completion: .default)
var scheme: String 

The Shell itself might look like this:

import Foundation

private struct Shell {
  
    private let zsh = "/bin/zsh"
    private let env = ProcessInfo.processInfo.environment
    
    @discardableResult
    func run(command: String) -> String? {
        print("Executing: \"\(command)\"...") // print will use the stdOut
        
        let process = Process()
        let stdOut = Pipe()
        let stdErr = Pipe()
        
        process.environment = env
        process.standardOutput = stdOut
        process.standardError = stdOut
        process.arguments = ["-c" + command]
        process.launch()
        
        output(to: stdOut)
        process.waitUntilExit()
      
        exit(process.terminationStatus)
    }
}

// MARK: - Private

private extension Shell {
    
    func output(to pipe: Pipe) {
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        let outputString = String(decoding: data, as: UTF8.self)
        print(outputString)
    }
} 

Here we made some assumptions about the dev's and CI's environments, but in macOS, a zsh shell is set as default from macOS Catalina. We can also move it to the execution environment.

Now we need to build our executable for macOS and give it the same permissions as in the first scenario with chmod. With that in place, we could start using our brand-new CLI!

3. What we achieved

By writing command line tools in Swift, we decrease the entry threshold for new developers and simplify our overall project setup.

We can reuse our scripts for both developers and CI. At the same time, we maintain readability and open the road for other devs to contribute to our CI pipelines without any prior knowledge of Ruby or Bash.

This, of course, is just a starting point for our CLI. Writing a signing framework like Fastlane match will require a whole other effort and will be covered in another article.

Tags:
Hubs:
+2
Comments 5
Comments Comments 5

Articles