Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions Sources/BinaryDependencyManager/Utils/CLI/CLI+GitHub.swift
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
41 changes: 41 additions & 0 deletions Sources/BinaryDependencyManager/Utils/CLI/CLI.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions Tests/BinaryDependencyManagerTests/CLITests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
}