Building a command line tool using the Swift Package Manager
This week, I’d like to provide a step-by-step tutorial for the setup that I use to build command line tools using the Swift Package Manager.
I personally really like the Swift Package Manager (which I will from now on refer to as ‘SPM’ to save me some typing 😅) and how easy it is to use once you get up and running. But it does have somewhat of a learning curve. So hopefully this post will make it easier for anyone looking to build their own tooling using Swift.
Getting started
To start building a command line tool, make a new directory and initialize it using SPM:
$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable
The type executable
above tells SPM that you want to build a command line tool, rather than a framework.
What’s in the box?
After initializing your directory, it will have the following contents:
- A
Package.swift
file, which defines your package (even command line tools are packages) and its dependencies. - A Sources folder, where you put your source code. It will initially contain a
main.swift
file, which is the entry point to your command line tool (you can’t rename that file). - A
Tests
folder, where you put your testing code. - A
.gitignore
file, which will make SPM’s build folder (.build
) and any Xcode projects you’ll generate ignored by source control.
Splitting your code up into a framework and an executable
One thing I’d recommend that you do right away is to create two modules for your sources — one framework and one executable. This will make testing a lot easier, and will also (and this is really cool) enable your command line tool to also be used as a dependency in other tools.
To make this happen, first, create two folders in Sources
, one for the executable and one for the framework, like this:
$ cd Sources
$ mkdir CommandLineToolCore
One really nice aspect of SPM is that it uses the file system as its “source of truth”, so simply creating new folders enables you to define new modules.
Next, update Package.swift
to define two targets — one for the CommandLineTool
module and one for CommandLineToolCore
:
import PackageDescription
let package = Package(
name: "CommandLineTool",
targets: [
.target(
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(name: "CommandLineToolCore")
]
)
As you can see above, we make the executable depend on the framework.
Using Xcode
In order to get proper code completion and be able to easily run/debug your command line tool — you probably want to use Xcode. The good news is that SPM can easily generate an Xcode project for you based on the file system. And, since the Xcode project is git ignored, you don’t have to deal with conflicts and updates to it — you simply just re-generate it when needed.
To generate an Xcode project, run the following command in the root folder:
$ swift package generate-xcodeproj
You’ll get a warning when running the above, but just ignore that for now, we’re about to fix it! 🙂
Defining a programmatic entry point
In order to be able to easily run your tool both from the command line and from your tests, it’s a good idea to not put too much functionality into main.swift, and instead enable your tool to be programmatically invoked.
To do this, create a CommandLineTool.swift
file in your framework module (Sources/CommandLineToolCore
) and add the following contents:
import Foundation
public final class CommandLineTool {
private let arguments: [String]
public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}
public func run() throws {
print("Hello world")
}
}
Next, simply call the above run()
method from main.swift
:
import CommandLineToolCore
let tool = CommandLineTool()
do {
try tool.run()
} catch {
print("Whoops! An error occurred: \(error)")
}
Hello world
Let’s take our command line tool for a spin! First, we’ll need to compile it. This is done by calling swift build
in the root folder of our package. After that, we can call swift run
to run it:
$ swift build
$ swift run
> Hello world
We could’ve just called swift run
above, since that’ll compile our project if needed, but it’s always useful to learn how the underlying commands work as well.
Adding dependencies
Unless you are building something a bit more trivial, you’re probably going to find yourself in need of adding some dependencies to your command line tool. Any Swift package can be added as a dependency, simply by specifying it in Package.swift
:
import PackageDescription
let package = Package(
name: "CommandLineTool",
dependencies: [
.package(
url: "https://github.com/johnsundell/files.git",
from: "4.0.0"
)
],
targets: [
.target(
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(
name: "CommandLineToolCore",
dependencies: ["Files"]
)
]
)
Above I add Files, which is a framework that enables you to easily handle files and folders in Swift. We will use Files to enable our command line tool to create a file in the current folder.
Installing dependencies
Once you’ve declared any new dependencies, simply ask SPM to resolve your dependencies and install them, and then re-generate the Xcode project:
$ swift package update
$ swift package generate-xcodeproj
Accepting arguments
Let’s modify CommandLineTool.swift
, to instead of printing Hello world
, now creating a file with the name taken from the arguments passed to the command line tool:
import Foundation
import Files
public final class CommandLineTool {
private let arguments: [String]
public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}
public func run() throws {
guard arguments.count > 1 else {
throw Error.missingFileName
}
// The first argument is the execution path
let fileName = arguments[1]
do {
try Folder.current.createFile(at: fileName)
} catch {
throw Error.failedToCreateFile
}
}
}
public extension CommandLineTool {
enum Error: Swift.Error {
case missingFileName
case failedToCreateFile
}
}
As you can see above, we wrap the call to Folder.current.createFile()
in our own do, try, catch
in order to provide a unified error API to our users.
Writing tests
We’re almost ready to ship our amazing new command line tool, but before we do — let’s ensure that it actually works as intended by writing some tests.
The good news is that, since we split up our tool into a framework and an executable early, testing it becomes very easy. All we have to do is to run it programmatically, and assert that it created a file with the name that was specified.
First add a test module to your Package.swift
file by adding the following in your targets
array:
.testTarget(
name: "CommandLineToolTests",
dependencies: ["CommandLineToolCore", "Files"]
)
Finally, re-generate your Xcode project:
$ swift package generate-xcodeproj
Open the Xcode project again, jump over to CommandLineToolTests.swift
and add the following contents:
import Foundation
import XCTest
import Files
import CommandLineToolCore
class CommandLineToolTests: XCTestCase {
func testCreatingFile() throws {
// Setup a temp test folder that can be used as a sandbox
let tempFolder = Folder.temporary
let testFolder = try tempFolder.createSubfolderIfNeeded(
withName: "CommandLineToolTests"
)
// Empty the test folder to ensure a clean state
try testFolder.empty()
// Make the temp folder the current working folder
let fileManager = FileManager.default
fileManager.changeCurrentDirectoryPath(testFolder.path)
// Create an instance of the command line tool
let arguments = [testFolder.path, "Hello.swift"]
let tool = CommandLineTool(arguments: arguments)
// Run the tool and assert that the file was created
try tool.run()
XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
}
}
It’s also a good idea to add tests to verify that the proper errors were thrown when a file name wasn’t given, or if the file creation failed.
To run your tests, simply run swift test
on the command line.
Installing your command line tool
Now that we’ve built and tested our command line tool, let’s install it to enable it to be run from anywhere on the command line. To do that, build the tool using the release configuration, and then move the compiled binary to /usr/local/bin
:
$ swift build -c release
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool
Done! 🎉
I hope you enjoyed this post. It’s the first “tutorial-ish” post that I’ve ever written, so would love your feedback on it. Also, let me know if you have any comments or questions, along with requests for any future weekly blog posts on Twitter @johnsundell.
Thanks for reading! 🚀