SwiftPM Command - Process gets stuck

Hi everyone. I'm trying to become more familiar with SPM and have started exploring the idea of building a small "SMP modularized project" command-line tool. I got stuck on a step where I am trying to use Process to basically execute swift package tools-version --set and swift package add-target.

The add-target command hangs when I run it from the command line, but it works as expected when I include the add-target launch argument and run the same command from Xcode.

The tools version is working well in both Xcode and the command line. I've created a sample repository GitHub - JozefLipovsky/CmdToolExample that hopefully better demonstrates what I'm trying to explain here. I would appreciate any advice.

I have a simple extension on ParsableCommand:

import ArgumentParser
import Foundation

extension ParsableCommand {
    func execute(arguments: [String]) throws {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
        process.arguments = arguments

        print("\(String(describing: type(of: self))) executing arguments: \(arguments.joined(separator: " "))")

        try process.run()
        process.waitUntilExit()

        print("\(String(describing: type(of: self))) finished executing")
    }
}

And then these 2 sub-commands as an example:

import ArgumentParser
import Foundation

struct UpdateToolsVersion: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "update-tools-version",
        abstract: "Updates Swift.package swift-tools-version"
    )

    mutating func run() throws {
        try execute(arguments: ["package", "tools-version", "--set", "5.9"])
    }
}
import ArgumentParser
import Foundation

struct AddTarget: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "add-target",
        abstract: "Adds a new target in Swift.package"
    )

    mutating func run() throws {
        try execute(arguments: ["package", "add-target", "NewTarget"])
    }
}

With the main command:

import ArgumentParser

public struct Commands: ParsableCommand {
    public static let configuration = CommandConfiguration(
        commandName: "cmd-tool",
        abstract: "A Swift command-line tool example.",
        shouldDisplay: true,
        subcommands: [
            UpdateToolsVersion.self,
            AddTarget.self
        ]
    )

    public init() {}
}

Package manifest:

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

import PackageDescription

let package = Package(
    name: "CmdTool",
    platforms: [
        .macOS(.v13)
    ],
    products: [
        .plugin(
            name: "CmdToolPlugin",
            targets: [
                "CmdToolPlugin"
            ]
        ),
        .executable(
            name: "CmdToolExecutable",
            targets: [
                "CmdToolExecutable"
            ]
        ),
    ],
    dependencies: [
        .package(
            url: "https://github.com/apple/swift-argument-parser.git",
            from: "1.2.0"
        )
    ],
    targets: [
        .plugin(
            name: "CmdToolPlugin",
            capability: .command(
                intent: .custom(
                    verb: "cmd-tool",
                    description: "A CMD tool example."
                ),
                permissions: [
                    .writeToPackageDirectory(reason: "Update SMP project config.")
                ]
            ),
            dependencies: [
                "CmdToolExecutable"
            ]
        ),

        .executableTarget(
            name: "CmdToolExecutable",
            dependencies: [
                "Commands"
            ]
        ),

        .target(
            name: "Commands",
            dependencies: [
                .product(
                    name: "ArgumentParser",
                    package: "swift-argument-parser"
                ),
            ]
        ),
    ]
)

I am assuming the issue is how the commands are executed directly when I build from Xcode with the scheme launch argument vs how they are executed via CmdToolPlugin from the command line

import PackagePlugin
import Foundation

@main
struct CmdToolPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        let tool = try context.tool(named: "CmdToolExecutable")

        let process = Process()
        process.executableURL = tool.url
        process.arguments = arguments

        print("\(String(describing: type(of: self))) performCommand arguments: \(arguments.joined(separator: " "))")

        try process.run()
        process.waitUntilExit()

        let gracefulExit = process.terminationReason == .exit && process.terminationStatus == 0
        if !gracefulExit {
            let reason = process.terminationReason.rawValue
            let status = process.terminationStatus
            throw "\(String(describing: type(of: self))) failed - reason: \(reason) - status: \(status)"
        }

        print("\(String(describing: type(of: self))) finished performCommand")
    }
}

I'm still confused about how UpdateToolsVersion works in both cases, and AddTarget always gets stuck in the command line. Do you have any idea what I might be doing wrong?

I spent more time today playing with this. I decided to create an even simpler example GitHub - JozefLipovsky/PluginCmdToolExample where:

  1. Instead of creating a Process instance with the URL of another package target/executable, I am trying to use the "swift" tool directly.
  2. I added a timeout mechanism with process termination.

So now I have:

@main
struct CmdToolPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        let swift = try context.tool(named: "swift")
        print("CmdToolPlugin - swift tool: \(swift.url)")

        let process = Process()
        process.executableURL = swift.url
        process.arguments = arguments

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        print("CmdToolPlugin - running with arguments: \(process.arguments ?? ["Missing Arguments!!!"])")
        try process.run()


        let timeout: TimeInterval = 3
        let startTime = Date()

        while process.isRunning {
            if Date().timeIntervalSince(startTime) > timeout {
                process.terminate()
                print("CmdToolPlugin - process timed out and was terminated.")
                break
            }

            try await Task.sleep(for: .seconds(1))
        }

        process.waitUntilExit()

        let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
        if let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty {
            print("CmdToolPlugin - process output: \(outputString)")
        }

        let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
        if let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty {
            print("CmdToolPlugin - process error: \(errorString)")
        }

        let status = process.terminationStatus
        print("CmdToolPlugin - process exited with status: \(status)")
    }
}

When I am testing swift package tools-version --set 5.9 command everything works as expected and Package.swift manifest is updated:

❯ swift package plugin --allow-writing-to-package-directory CmdToolPlugin package tools-version --set 5.9
CmdToolPlugin - swift tool: file:///Applications/Xcode-16.2.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift
CmdToolPlugin - running with arguments: ["package", "tools-version", "--set", "5.9"]
CmdToolPlugin - process exited with status: 0

But it got stuck when I am testing swift package plugin list, process timout mechanism kicks in, but it seems like the process remains stuck on waitUntilExit():

❯ swift package plugin --allow-writing-to-package-directory CmdToolPlugin package plugin list
CmdToolPlugin - swift tool: file:///Applications/Xcode-16.2.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift
CmdToolPlugin - running with arguments: ["package", "plugin", "list"]
CmdToolPlugin - process timed out and was terminated.
^C

Even more interesting, I discovered the --disable-sandbox flag. I am not sure if I understand when or how to use it, but when I do so while I am testing swift package plugin list, I am getting an error, and the process does exit :

❯ swift package --disable-sandbox plugin --allow-writing-to-package-directory CmdToolPlugin package plugin list
CmdToolPlugin - swift tool: file:///Applications/Xcode-16.2.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift
CmdToolPlugin - running with arguments: ["package", "plugin", "list"]
CmdToolPlugin - process timed out and was terminated.
CmdToolPlugin - process error: Another instance of SwiftPM is already running using '/Users/jozef/Developer/PluginCmdToolExample/.build', waiting until that process has finished execution...
CmdToolPlugin - process exited with status: 15

I assume I'm creating a deadlock when trying to use the SwiftPM process to call specific SwiftPM sub-commands. I'll continue investigating....