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
extensionrun
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.