🛠️

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を追加します。
https://github.com/apple/swift-argument-parser
https://github.com/swiftlang/swift-subprocess
https://github.com/tuist/Noora

Package.swift
// 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エントリーポイントを設定します。

Sources/tool/Tool.swift
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関数を作っておきます

Sources/tool/execute.swift
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
Sources/tool/Tool+Format.swift
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 {}
      }
    }
  }
}
Sources/tool/Tool.swift
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
Sources/tool/Tool+Install.swift
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\"")
    }
  }
}
Sources/tool/Tool.swift
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"

サーバー起動コマンド

たくさんのサーバー起動コマンドがある場合に毎回異なるコマンドを打つのは面倒です。いい感じに選択したサーバーをサクッと起動できる様にしましょう。

Sources/tool/Tool+Serve.swift
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()
    }
  }
}
Sources/tool/Tool.swift
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でスクリプトを管理することをお勧めします。

nextbeat Tech Blog

Discussion