diff --git a/Sources/BinaryDependencyManager/Utils/CLI/CLI+GitHub.swift b/Sources/BinaryDependencyManager/Utils/CLI/CLI+GitHub.swift new file mode 100644 index 0000000..ac2990b --- /dev/null +++ b/Sources/BinaryDependencyManager/Utils/CLI/CLI+GitHub.swift @@ -0,0 +1,95 @@ +import Foundation + +extension CLI { + /// Helper for interacting with the GitHub CLI (`gh`) + struct GitHub { + let cliURL: URL + } +} + +extension CLI.GitHub { + /// Initializes a `CLI.GitHub` by resolving the path to the `gh` command-line tool. + /// + /// - Throws: An error if the `gh` CLI cannot be found in PATH. + init() throws { + self.init(cliURL: try CLI.which(cliToolName: "gh")) + } +} + +extension CLI.GitHub { + /// Runs the GitHub CLI (`gh`) command with the given arguments. + /// + /// - Parameters: + /// - arguments: Arguments to pass to `gh`. + /// - currentDirectoryURL: Optional working directory for the command. + /// - Returns: The standard output as a string. + /// - Throws: An error if the process fails or cannot be run. + @discardableResult + func run(arguments: [String], currentDirectoryURL: URL? = .none) throws -> String { + try CLI.run( + executableURL: cliURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL + ) + } + + /// Downloads the source code as a zip archive from the specified repo and release tag using `gh`. + /// + /// - Parameters: + /// - repo: GitHub repository, e.g. "owner/repo". + /// - tag: The release tag to download. + /// - outputFilePath: The output file path for the downloaded archive. + /// - Throws: If the download fails. + func downloadSourceCode( + repo: String, + tag: String, + outputFilePath: String + ) throws { + let arguments: [String] = [ + ["release"], + ["download"], + ["\(tag)"], + ["--archive=zip"], + ["--repo", "\(repo)"], + ["--output", "\(outputFilePath)"] + ].flatMap { $0 } + + Logger.log("[Download] ⬇️ \(repo) source code with tag \(tag) to \(outputFilePath)") + + try run( + arguments: arguments, + currentDirectoryURL: outputFilePath.asFileURL.deletingLastPathComponent() + ) + } + + /// Downloads a specific release asset from GitHub matching the provided pattern using `gh`. + /// + /// - Parameters: + /// - repo: GitHub repository, e.g. "owner/repo". + /// - tag: The release tag to download. + /// - pattern: Optional asset name pattern to select the correct file. + /// - outputFilePath: Where to save the downloaded asset. + /// - Throws: If the download fails. + func downloadReleaseAsset( + repo: String, + tag: String, + pattern: String?, + outputFilePath: String + ) throws { + let arguments: [String] = [ + ["release"], + ["download"], + ["\(tag)"], + pattern.map { ["--pattern", "\($0)"] } ?? [], + ["--repo", "\(repo)"], + ["--output", "\(outputFilePath)"] + ].flatMap { $0 } + + Logger.log("[Download] ⬇️ \(repo) release asset with tag \(tag) to \(outputFilePath)") + + try run( + arguments: arguments, + currentDirectoryURL: outputFilePath.asFileURL.deletingLastPathComponent() + ) + } +} diff --git a/Sources/BinaryDependencyManager/Utils/CLI/CLI.swift b/Sources/BinaryDependencyManager/Utils/CLI/CLI.swift new file mode 100644 index 0000000..c25b4a3 --- /dev/null +++ b/Sources/BinaryDependencyManager/Utils/CLI/CLI.swift @@ -0,0 +1,41 @@ +import Foundation + +enum CLI { + /// Returns path to the provided `cliToolName`. + /// + /// - Parameters: + /// - `cliToolName`: A tool name to locate. + static func which(cliToolName: String) throws -> URL { + do { + let cliPath = try run(executableURL: URL(fileURLWithPath: "/usr/bin/which"), arguments: [cliToolName]) + return URL(fileURLWithPath: cliPath) + } catch { + Logger.log("Error: \(error)".red) + throw NSError(domain: "Can't find \(cliToolName) command line tool", code: 0) + } + } + + /// Returns stdout returned by the execution of `executableURL` with given `arguments`. + /// - Parameters: + /// - executableURL: An URL to the executable to run. + /// - arguments: A list of arguments to pass to the executable invocation. + /// - currentDirectoryURL: A working directory URL where executable will be launched. + @discardableResult + static func run(executableURL: URL, arguments: [String], currentDirectoryURL: URL? = .none) throws -> String { + Logger.log("[Run] \(executableURL.path) \(arguments.joined(separator: " "))") + + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.currentDirectoryURL = currentDirectoryURL + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let stdout = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw NSError(domain: "Can't parse output from the \(executableURL.path)", code: 0) + } + return stdout + } +} diff --git a/Tests/BinaryDependencyManagerTests/CLITests.swift b/Tests/BinaryDependencyManagerTests/CLITests.swift new file mode 100644 index 0000000..b33932f --- /dev/null +++ b/Tests/BinaryDependencyManagerTests/CLITests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import binary_dependencies_manager + +final class CLITests: XCTestCase { + func testWhichFindsExistingTool() throws { + let url = try CLI.which(cliToolName: "ls") + XCTAssertTrue(url.path.hasSuffix("/ls"), "Should find 'ls' command") + } + + func testWhichThrowsForNonexistentTool() { + XCTAssertThrowsError(try CLI.which(cliToolName: "nonexistent_tool_12345")) { error in + let nsError = error as NSError + XCTAssertTrue(nsError.domain.contains("nonexistent_tool_12345")) + } + } +}