Creating a CLI executable in an artifact bundle

Posted Tuesday, September 5, 2023.

Introduction

An SPM build plugin often needs an executable target to run its functionality. While you can co-locate the package plugin's source code with the executable code in the same package, this will cause an extra build step for all consumers as the executable needs to be built before it can be used in a plugin.

If your cli tool has no external dependencies, then the executable might be able to be part of the same package as the plugin and any consuming targets. However, I encountered a build problem when building an iOS project that was consuming a library that used a build plugin. When all the code (cli, plugin, library) were part of the same package, the build failed. Referencing the CLI as a binary target instead of a package that needed to be built fixed the problem (this might have been fixed since I ran into that problem).

We can mitigate these problems by taking our CLI and building it into an artifact bundle to be consumed by the package plugin as a binary target. In this post we will detail the steps to create an executable CLI in an artifact bundle.

We will be creating a CLI called MyCLI. It will only have one command which will generate some swift code that will access the contents of some json files. This generated code and resources will go into the the consuming target's build plugin directory and shows how we can generate swift code and also include non-swift resources which the bundle can then access as normal (albeit read-only) code/resources.

The benefit of making this as a standalone CLI is that it is more modular and easier to test, and if applicable, it can be used elsewhere.

Package Structure

The finished file structure of this MyCLI package will look as such (when you are starting out, MyCLI.artifactbundle will not exist):

.
├── MyCLI.artifactbundle
│ ├── info.json
│ └── my-cli-0.0.1-macos
│ └── my-cli
├── Sources
│ └── MyCLI
│ └── MyCLI.swift
├── Package.swift
└── Package.resolved

Package.swift

We will use the ArgumentParser library to build our CLI. Package.swift will contain the following:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "my-cli",
products: [
.executable(name: "MyCLI", targets: ["MyCLI"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0")
],
targets: [
.executableTarget(name: "MyCLI", dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
])
]
)

CLI Command

Our MyCLI command will takes 3 URLs as arguments:

  • json file containing inputs to read
  • output file to write the swift code to
  • resources folder to write resource files to

In real usage, these URLs will come from the package plugin we will write in a future post

In our case, we are going to have the generated code be an extension on some type that will exist in the target that utilizes the package plugin. This is not a requirement.

import ArgumentParser
import Foundation

@main
struct MyCLI: ParsableCommand {
static let fileURL: (String) throws -> URL = { URL(fileURLWithPath: $0) }

// input file location
@Option(completion: .file(extensions: ["json"]), transform: MyCLI.fileURL)
var input: URL

// output swift file location
@Option(completion: .file(extensions: ["swift"]), transform: MyCLI.fileURL)
var generated: URL

// output resources directory
@Option(completion: .directory, transform: MyCLI.fileURL)
var resources: URL

func run() throws {
let data = try Data(contentsOf: input)
let info = try JSONDecoder().decode([String: String].self, from: data)

try writeResources(info, to: resources)
try writeSwift(info, to: generated)
}

func writeResources(_ info: [String: String], to url: URL) throws {
for (key, value) in info {
let file = url.appendingPathComponent("\(key).txt")
try Data(value.utf8).write(to: file)
}
}

func writeSwift(_ info: [String: String], to url: URL) throws {
let swift = info.keys.map { key in
"""
static var \(key): String {
let url = Bundle.module.url(forResource: "\(key)", withExtension: "txt")!
let data = try! Data(contentsOf: url)
return String(decoding: data, as: UTF8.self)
}
"""
}

try Data("""
import Foundation

extension MyCoolType {
\(swift.joined(separator: "\n\n"))
}
""".utf8).write(to: url)
}
}

A few things to note...

  • The "keys" in this case would need to be values that are safe to be file names
  • In real life we would have proper error handling and not all the force unwrapping!
  • We use Bundle.module because these text files are going to be located in the generated bundle
  • This is a super contrived example

Creating an Artifact Bundle

We are only going to support macOS in this example. If you end up using this for iOS purposes or on mac only hardware, this will be enough because Xcode doesn't exist on Linux platforms. In a future post we can explore how to also generate for linux platforms.

An artifact bundle contains an info.json file along with any built binaries. The example below shows a single CLI executable with support for x86_64 and arm64 macOS support.

{
"schemaVersion": "1.0",
"artifacts": {
"my-cli": {
"version": "0.0.1",
"type": "executable",
"variants": [
{
"path": "my-cli-0.0.1-macos/my-cli",
"supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"]
}
]
}
}
}

Create a folder MyCLI.artifactbundle, and a file in it info.json with the contents of the above example.

In order to actually create the executable binary and move it into the archive, we can run the following commands

# create an archive of the binary
xcodebuild archive \
-workspace . \
-scheme MyCLI \
-destination 'platform=macOS,arch=x86_64' \
-destination 'platform=macOS,arch=arm64' \
-archivePath "macos_devices.xcarchive" \
BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
SKIP_INSTALL=NO

# copy it into the bundle
cp -f \
./macos_devices.xcarchive/Products/usr/local/bin/MyCLI \
./MyCLI.artifactbundle/my-cli-0.0.1-macos/my-cli

As you change/update this CLI you would probably update the version number/path/etc - this example just assumes 0.0.1

Now we have an executable that can be run on both x86_64 and arm64 macs! We can use this as a binary target in our SPM package plugin implementation as well.

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
...,
targets: [
...,
.binaryTarget(name: "MyCLI", path: "../Path/To/MyCLI/MyCLI.artifactbundle")
]
)

In a future post we will explore how to use this artifact bundle in a package build plugin.


Tagged With: