Swiftで自在な開発用スクリプト定義を管理しよう
概要
モノレポが人気になってきたこの時代、formatやlint, test, build, Webサーバーの起動などが複数アプリケーションがあると手間がかかることが多いです。Makefileなどでは上手くできないこともあると思います。そこで、Swiftで簡単にCLIを作ることによって、複雑な起動スクリプトもわかりやすくシンプルに構築し、簡単に実行できることを目指します。
結論
Swiftで自作CLIモジュールを作ってしまえば、Swiftでタスク定義ができる(ちょっと暴論)
作り方
プロジェクトを用意
ディレクトリとSwiftコマンドでSPMプロジェクトを作成します。
mkdir tool
cd tool
swift package init --type executable --name tool
依存関係の設定
CLIを簡単に作成するためのApple公式の定番ライブラリswift-argument-parser
と割と最近公開されたコマンドを簡単に実行するためのSwift公式ライブラリswift-subprocess
。yes or noや選択肢の選択などをターミナルで出来る様にするライブラリNoora
を追加します。
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "tool",
platforms: [.macOS(.v15)],
dependencies: [
+ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
+ .package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"),
+ .package(url: "https://github.com/tuist/Noora", from: "0.49.0"),
],
targets: [
.executableTarget(
name: "tool",
dependencies: [
+ .product(name: "ArgumentParser", package: "swift-argument-parser"),
+ .product(name: "Subprocess", package: "swift-subprocess"),
+ .product(name: "Noora", package: "Noora"),
],
swiftSettings: swiftSettings,
)
],
swiftLanguageModes: [.v6],
)
var swiftSettings: [SwiftSetting] {
[
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("NonescapableTypes"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
}
実行するエントリーポイントの実装
Tool.swiftを作成し、ArgumentParserで@main
エントリーポイントを設定します。
import ArgumentParser
@main
struct Tool: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "開発環境のスクリプト実行ツール"
)
}
実行すると以下のように空のCLIが出来ます。
% swift run tool
[1/1] Planning build
Building for debugging...
[11/11] Applying tool
Build of product 'tool' complete! (1.43s)
OVERVIEW: 開発環境のスクリプト実行ツール
USAGE: tool
OPTIONS:
-h, --help Show help information.
Formatterの実装
tool fmt
で一発で並行で複数プロジェクトのformatが行えるように実装します。
先にSubprocess.runをwrapしてデフォルト出力を標準出力にしたexecute関数を作っておきます
import Foundation
import Subprocess
import System
func execute<Output: OutputProtocol, Error: OutputProtocol>(
_ executable: Executable,
arguments: Arguments = [],
workingDirectory: FilePath? = nil,
output: Output = FileDescriptorOutput.fileDescriptor(.standardOutput, closeAfterSpawningProcess: false),
error: Error = FileDescriptorOutput.fileDescriptor(.standardError, closeAfterSpawningProcess: false),
) async throws {
let output = try await Subprocess.run(
executable,
arguments: arguments,
workingDirectory: workingDirectory,
output: output,
error: error,
)
guard output.terminationStatus.isSuccess else { throw ExitCode.failure }
}
次にFormatコマンドを定義します。以下のコマンドを並行実行しています。個人的にMarkdownのformatやlintにdenoが気に入っています。
swift-format --in-place --recursive .
deno fmt ./**/*.md
sbt scalafmt
import ArgumentParser
extension Tool {
struct Format: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "format",
abstract: "コードを整形します",
aliases: ["fmt"],
)
mutating func run() async throws {
try await withThrowingTaskGroup { group in
group.addTask {
try await execute(
.name("swift"),
arguments: ["format", "--in-place", "--recursive", "."],
workingDirectory: "swift-server",
)
}
group.addTask {
try await execute(.name("deno"), arguments: ["fmt", "./**/*.md"])
}
group.addTask {
try await execute(.name("sbt"), arguments: ["scalafmt"], workingDirectory: "scala-server")
}
for try await _ in group {}
}
}
}
}
import ArgumentParser
@main
struct Tool: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "開発環境のスクリプト実行ツール",
+ subcommands: [Format.self],
)
}
実行するとformat, fmtのサブコマンドが追加されています。swift run tool fmt
などで実行できます。
% swift run tool
Building for debugging...
[11/11] Applying tool
Build of product 'tool' complete! (1.26s)
OVERVIEW: 開発環境のスクリプト実行ツール
USAGE: tool <subcommand>
OPTIONS:
-h, --help Show help information.
SUBCOMMANDS:
format, fmt コードを整形します
See 'tool help <subcommand>' for detailed help.
インストールコマンド
tool CLIをインストールするためのコマンドを追加します。コツはcp
ではなくmv
を使用することです。2回目以降にcp
で上書きすると現在実行中の実行ファイルを上書きしようとして失敗しますが、mv
だと原子的に置き換えられます。
やってることは以下の様なことです。
swift build -c release
chmod +x .build/release/tool
mv .build/release/tool ~/.local/bin/tool
import ArgumentParser
import Foundation
extension Tool {
struct Install: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "toolをインストールします",
aliases: ["i"],
)
mutating func run() async throws {
let fileManager: FileManager = .default
let buildURL = URL(fileURLWithPath: ".build/release/tool")
let installURL = fileManager.homeDirectoryForCurrentUser
.appending(path: ".local")
.appending(path: "bin")
.appending(path: "tool")
try await execute(.name("swift"), arguments: ["build", "-c", "release"])
try await execute(.name("chmod"), arguments: ["+x", buildURL.path()])
try await execute(.name("mv"), arguments: [buildURL.path(), installURL.path()])
print("\(installURL.path())に実行ファイルをインストールしました")
print("コマンドが見つからない場合には")
print("export PATH=\"$HOME/.local/bin:$PATH\"")
}
}
}
import ArgumentParser
@main
struct Tool: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "開発環境のスクリプト実行ツール",
+ subcommands: [Format.self, Install.self],
)
}
インストールできます。
% swift run tool -h
[1/1] Planning build
Building for debugging...
[11/11] Applying tool
Build of product 'tool' complete! (1.23s)
OVERVIEW: 開発環境のスクリプト実行ツール
USAGE: tool <subcommand>
OPTIONS:
-h, --help Show help information.
SUBCOMMANDS:
format, fmt コードを整形します
install, i toolをインストールします
See 'tool help <subcommand>' for detailed help.
% swift run tool i
Building for debugging...
[1/1] Write swift-version-239F2A40393FBBF.txt
Build of product 'tool' complete! (0.15s)
[1/1] Planning build
Building for production...
[7/7] Linking tool
Build complete! (4.22s)
/Users/lemo-nade-room/.local/bin/toolに実行ファイルをインストールしました
コマンドが見つからない場合には
export PATH="$HOME/.local/bin:$PATH"
サーバー起動コマンド
たくさんのサーバー起動コマンドがある場合に毎回異なるコマンドを打つのは面倒です。いい感じに選択したサーバーをサクッと起動できる様にしましょう。
import ArgumentParser
import Noora
import Subprocess
import System
extension Tool {
struct Serve: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "serve",
abstract: "ローカルサーバーを起動します",
)
enum Server: String, CaseIterable, Hashable, CustomStringConvertible {
case angular
case scala
case swift
var description: String { rawValue }
func run() async throws {
switch self {
case .angular:
let workingDirectory = FilePath("front")
try await execute(.name("yarn"), workingDirectory: workingDirectory)
try await execute(.name("yarn"), arguments: ["dev"], workingDirectory: workingDirectory)
case .scala:
try await execute(.name("sbt"), arguments: ["run"], workingDirectory: "scala-server")
case .swift:
try await execute(.name("swift"), arguments: ["run"], workingDirectory: "swift-server")
}
}
}
mutating func run() async throws {
let noora = Noora()
let server: Server = noora.singleChoicePrompt(
title: "開発サーバー",
question: "どれを起動しますか?",
)
try await server.run()
}
}
}
import ArgumentParser
@main
struct Tool: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "開発環境のスクリプト実行ツール",
+ subcommands: [Format.self, Install.self, Serve.self],
)
}
インストールしたtool
コマンドでサーバー起動ができる様になりました。
% tool serve
あとがき
MakefileだとProtobufやOpenAPIからの生成や依存関係のbuildやassetsのdownloadなど手順が多い場合に対応できないことが多かったりして、何か良いツールがないかと思ってChatGPTに聞いてみたらSwift Package Pluginを自作することを勧められ、自作してみると、これ普通にCLI作った方が早いし楽だと気づいたため、CLIをサクッと作ることにしました。なかなか綺麗にかけて何よりSwiftで型強くかけるのが魅力的ですね。denoかBunで書いた方が早いかもしれませんが、Server-Side SwiftであればSwift Package Managerをモノレポの第一階層としてSwiftでスクリプトを管理することをお勧めします。
Discussion