diff --git a/Package.resolved b/Package.resolved index da187b5a..e904c45e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "cd85ed855cdbb8c16b4f3d5b455eecaae7131bd792547e25a5124ccf486113c2", + "originHash" : "f95cf109954483e637b3157eb63792ab8362707f4fb93c0e30ff5ad7638fc82d", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client", "state" : { - "revision" : "7dc119c7edf3c23f52638faadb89182861dee853", - "version" : "1.28.0" + "revision" : "8430dd49d4e2b417f472141805c9691ec2923cb8", + "version" : "1.29.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattpolzin/OpenAPIKit", "state" : { - "revision" : "c1dcd65ccbcc1f3e132293ff2338c72c49a9026d", - "version" : "3.8.0" + "revision" : "343b2c1793058fcc53c1bd7e2907f8e3a4d640fb", + "version" : "3.9.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" + "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", + "version" : "1.6.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", - "version" : "1.4.0" + "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", + "version" : "1.5.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "4b092f15164144c24554e0a75e080a960c5190a6", - "version" : "1.14.0" + "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", + "version" : "1.15.0" } }, { @@ -87,8 +87,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "revision" : "bcd2b89f2a4446395830b82e4e192765edd71e18", + "version" : "4.0.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version" : "1.3.1" } }, { @@ -114,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", - "version" : "1.4.0" + "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", + "version" : "1.5.0" } }, { @@ -123,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -141,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", - "version" : "2.86.2" + "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", + "version" : "2.87.0" } }, { @@ -168,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", - "version" : "2.34.1" + "revision" : "d3bad3847c53015fe8ec1e6c3ab54e53a5b6f15f", + "version" : "2.35.0" } }, { @@ -177,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "e645014baea2ec1c2db564410c51a656cf47c923", - "version" : "1.25.1" + "revision" : "df6c28355051c72c884574a6c858bc54f7311ff9", + "version" : "1.25.2" } }, { @@ -217,13 +226,31 @@ "version" : "1.8.3" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version" : "1.2.1" + } + }, { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "revision" : "0fcc4c9c2d58dd98504c06f7308c86de775396ff", + "version" : "2.9.0" + } + }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess", + "state" : { + "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd", + "version" : "0.2.1" } }, { @@ -231,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", + "version" : "1.5.0" } }, { @@ -258,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "d41ba4e7164c0838c6d48351f7575f7f762151fe", - "version" : "6.1.0" + "revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f", + "version" : "6.2.0" } } ], diff --git a/Package.swift b/Package.swift index 9b5b77ab..2351b49a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 import PackageDescription @@ -31,6 +31,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.7.2"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.2"), .package(url: "https://github.com/apple/swift-system", from: "1.4.2"), + .package(url: "https://github.com/swiftlang/swift-subprocess", exact: "0.2.1", traits: []), // This dependency provides the correct version of the formatter so that you can run `swift run swiftformat Package.swift Plugins/ Sources/ Tests/` .package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.49.18"), ], @@ -67,6 +68,7 @@ let package = Package( .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIAsyncHTTPClient", package: "swift-openapi-async-http-client"), .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Subprocess", package: "swift-subprocess"), ], swiftSettings: swiftSettings, plugins: ["GenerateCommandModels"] diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 59296d6f..ab4773f2 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess import SwiftlyCore import SystemPackage @@ -263,7 +264,13 @@ public struct Linux: Platform { } if requireSignatureValidation { - guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else { + let result = try await run( + .name("gpg"), + arguments: ["--version"], + output: .discarded + ) + + if !result.terminationStatus.isSuccess { var msg = "gpg is not installed. " if let manager { msg += """ @@ -283,11 +290,9 @@ public struct Linux: Platform { try await fs.withTemporary(files: tmpFile) { try await ctx.httpClient.getGpgKeys().download(to: tmpFile) if let mockedHomeDir = ctx.mockedHomeDir { - var env = ProcessInfo.processInfo.environment - env["GNUPGHOME"] = (mockedHomeDir / ".gnupg").string - try await sys.gpg()._import(key: tmpFile).run(self, env: env, quiet: true) + try await sys.gpg()._import(key: tmpFile).run(environment: .inherit.updating(["GNUPGHOME": (mockedHomeDir / ".gnupg").string]), quiet: true) } else { - try await sys.gpg()._import(key: tmpFile).run(self, quiet: true) + try await sys.gpg()._import(key: tmpFile).run(quiet: true) } } } @@ -315,7 +320,12 @@ public struct Linux: Platform { do { switch manager { case "apt-get": - if let pkgList = try await self.runProgramOutput("dpkg", "-l", package) { + let result = try await run(.name("dpkg"), arguments: ["-l", package], output: .string(limit: 100 * 1024)) + if !result.terminationStatus.isSuccess { + return false + } + + if let pkgList = result.standardOutput { // The package might be listed but not in an installed non-error state. // // Look for something like this: @@ -329,8 +339,8 @@ public struct Linux: Platform { } return false case "yum": - try self.runProgram("yum", "list", "installed", package, quiet: true) - return true + let result = try await run(.name("yum"), arguments: ["list", "installed", package], output: .discarded) + return result.terminationStatus.isSuccess default: return true } @@ -390,7 +400,15 @@ public struct Linux: Platform { tmpDir / String(name) } - try self.runProgram((tmpDir / "swiftly").string, "init") + let config = Configuration( + executable: .path(tmpDir / "swiftly"), + arguments: ["init"] + ) + + let result = try await run(config, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: config) + } } } @@ -424,11 +442,9 @@ public struct Linux: Platform { await ctx.message("Verifying toolchain signature...") do { if let mockedHomeDir = ctx.mockedHomeDir { - var env = ProcessInfo.processInfo.environment - env["GNUPGHOME"] = (mockedHomeDir / ".gnupg").string - try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(self, env: env, quiet: false) + try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(environment: .inherit.updating(["GNUPGHOME": (mockedHomeDir / ".gnupg").string]), quiet: false) } else { - try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(self, quiet: !verbose) + try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(quiet: !verbose) } } catch { throw SwiftlyError(message: "Signature verification failed: \(error).") @@ -453,11 +469,9 @@ public struct Linux: Platform { await ctx.message("Verifying swiftly signature...") do { if let mockedHomeDir = ctx.mockedHomeDir { - var env = ProcessInfo.processInfo.environment - env["GNUPGHOME"] = (mockedHomeDir / ".gnupg").string - try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(self, env: env, quiet: false) + try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(environment: .inherit.updating(["GNUPGHOME": (mockedHomeDir / ".gnupg").string]), quiet: false) } else { - try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(self, quiet: !verbose) + try await sys.gpg().verify(detached_signature: sigFile, signed_data: archive).run(quiet: !verbose) } } catch { throw SwiftlyError(message: "Signature verification failed: \(error).") @@ -611,7 +625,7 @@ public struct Linux: Platform { public func getShell() async throws -> String { let userName = ProcessInfo.processInfo.userName - if let entry = try await sys.getent(database: "passwd", key: userName).entries(self).first { + if let entry = try await sys.getent(database: "passwd", key: userName).entries().first { if let shell = entry.last { return shell } } diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index 3e9b40de..b68f1ca1 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess import SwiftlyCore import SystemPackage @@ -71,7 +72,7 @@ public struct MacOS: Platform { // If the toolchains go into the default user location then we use the installer to install them await ctx.message("Installing package in user home directory...") - try await sys.installer(.verbose, .pkg(tmpFile), .target("CurrentUserHomeDirectory")).run(self, quiet: !verbose) + try await sys.installer(.verbose, .pkg(tmpFile), .target("CurrentUserHomeDirectory")).run() } else { // Otherwise, we extract the pkg into the requested toolchains directory. await ctx.message("Expanding pkg...") @@ -84,7 +85,7 @@ public struct MacOS: Platform { await ctx.message("Checking package signature...") do { - try await sys.pkgutil().checksignature(pkg_path: tmpFile).run(self, quiet: !verbose) + try await sys.pkgutil().checksignature(pkg_path: tmpFile).run(quiet: !verbose) } catch { // If this is not a test that uses mocked toolchains then we must throw this error and abort installation guard ctx.mockedHomeDir != nil else { @@ -94,7 +95,7 @@ public struct MacOS: Platform { // We permit the signature verification to fail during testing await ctx.message("Signature verification failed, which is allowable during testing with mocked toolchains") } - try await sys.pkgutil(.verbose).expand(pkg_path: tmpFile, dir_path: tmpDir).run(self, quiet: !verbose) + try await sys.pkgutil(.verbose).expand(pkg_path: tmpFile, dir_path: tmpDir).run(quiet: !verbose) // There's a slight difference in the location of the special Payload file between official swift packages // and the ones that are mocked here in the test framework. @@ -104,7 +105,7 @@ public struct MacOS: Platform { } await ctx.message("Untarring pkg Payload...") - try await sys.tar(.directory(toolchainDir)).extract(.verbose, .archive(payload)).run(self, quiet: !verbose) + try await sys.tar(.directory(toolchainDir)).extract(.verbose, .archive(payload)).run(quiet: !verbose) } } @@ -120,8 +121,8 @@ public struct MacOS: Platform { try await sys.installer( .pkg(archive), .target("CurrentUserHomeDirectory") - ).run(self) - try? await sys.pkgutil(.volume(userHomeDir)).forget(pkg_id: "org.swift.swiftly").run(self) + ).run() + try? await sys.pkgutil(.volume(userHomeDir)).forget(pkg_id: "org.swift.swiftly").run() } else { let installDir = userHomeDir / ".swiftly" try await fs.mkdir(.parents, atPath: installDir) @@ -129,7 +130,7 @@ public struct MacOS: Platform { // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because // the installer will not install to an arbitrary path, only a volume or user home directory. let tmpDir = fs.mktemp() - try await sys.pkgutil().expand(pkg_path: archive, dir_path: tmpDir).run(self) + try await sys.pkgutil().expand(pkg_path: archive, dir_path: tmpDir).run() // There's a slight difference in the location of the special Payload file between official swift packages // and the ones that are mocked here in the test framework. @@ -139,10 +140,16 @@ public struct MacOS: Platform { } await ctx.message("Extracting the swiftly package into \(installDir)...") - try await sys.tar(.directory(installDir)).extract(.verbose, .archive(payload)).run(self, quiet: false) + try await sys.tar(.directory(installDir)).extract(.verbose, .archive(payload)).run(quiet: false) } - try self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") + let config = Configuration( + .path(FilePath((userHomeDir / ".swiftly/bin/swiftly").string)), arguments: ["init"] + ) + let result = try await run(config, input: .standardInput, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: config) + } } public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) @@ -164,7 +171,7 @@ public struct MacOS: Platform { try await fs.remove(atPath: toolchainDir) - try? await sys.pkgutil(.volume(fs.home)).forget(pkg_id: pkgInfo.CFBundleIdentifier).run(self, quiet: !verbose) + try? await sys.pkgutil(.volume(fs.home)).forget(pkg_id: pkgInfo.CFBundleIdentifier).run(quiet: !verbose) } public func getExecutableName() -> String { @@ -205,7 +212,7 @@ public struct MacOS: Platform { { if toolchain == .xcodeVersion { // Print the toolchain location with the help of xcrun - if let xcrunLocation = try? await self.runProgramOutput("/usr/bin/xcrun", "-f", "swift") { + if let xcrunLocation = try? await run(.path(FilePath("/usr/bin/xcrun")), arguments: ["-f", "swift"], output: .string(limit: 1024 * 10)).standardOutput { return FilePath(xcrunLocation.replacingOccurrences(of: "\n", with: "")).removingLastComponent().removingLastComponent().removingLastComponent() } } diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift index f79be4e8..6dd4bdeb 100644 --- a/Sources/Swiftly/Proxy.swift +++ b/Sources/Swiftly/Proxy.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Subprocess import SwiftlyCore @main @@ -67,11 +68,32 @@ public enum Proxy { guard ProcessInfo.processInfo.environment["SWIFTLY_PROXY_IN_PROGRESS"] == nil else { throw SwiftlyError(message: "Circular swiftly proxy invocation") } - let env = ["SWIFTLY_PROXY_IN_PROGRESS": "1"] - try await Swiftly.currentPlatform.proxy(ctx, toolchain, binName, Array(CommandLine.arguments[1...]), env) + let env = try await Swiftly.currentPlatform.proxyEnvironment(ctx, env: .inherit, toolchain: toolchain) + + let cmdConfig = Configuration( + .name(binName), + arguments: Arguments(Array(CommandLine.arguments[1...])), + environment: env.updating(["SWIFTLY_PROXY_IN_PROGRESS": "1"]) + ) + + let cmdResult = try await Subprocess.run( + cmdConfig, + input: .standardInput, + output: .standardOutput, + error: .standardError + ) + + if !cmdResult.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: cmdResult.terminationStatus, config: cmdConfig) + } } catch let terminated as RunProgramError { - exit(terminated.exitCode) + switch terminated.terminationStatus { + case let .exited(code): + exit(code) + case .unhandledException: + exit(1) + } } catch let error as SwiftlyError { await ctx.message(error.message) exit(1) diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift index 21c1bb2c..28b07e3b 100644 --- a/Sources/Swiftly/Run.swift +++ b/Sources/Swiftly/Run.swift @@ -1,6 +1,8 @@ import ArgumentParser import Foundation +import Subprocess import SwiftlyCore +import SystemPackage struct Run: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -93,24 +95,47 @@ struct Run: SwiftlyCommand { throw SwiftlyError(message: "No installed swift toolchain is selected from either from a .swift-version file, or the default. You can try using one that's already installed with `swiftly use ` or install a new toolchain to use with `swiftly install --use `.") } - do { - if let outputHandler = ctx.outputHandler { - if let output = try await Swiftly.currentPlatform.proxyOutput(ctx, toolchain, command[0], [String](command[1...])) { - for line in output.split(separator: "\n") { - await outputHandler.handleOutputLine(String(line)) - } + let env: Environment = try await Swiftly.currentPlatform.proxyEnvironment(ctx, env: .inherit, toolchain: toolchain) + + let commandPath = FilePath(command[0]) + let executable: Executable + if try await fs.exists(atPath: commandPath) { + executable = .path(commandPath) + } else { + // Search the toolchain ourselves to find the correct executable path. Subprocess's default search + // paths will interfere with preferring the selected toolchain over system toolchains. + let tcBinPath = try await Swiftly.currentPlatform.findToolchainLocation(ctx, toolchain) / "usr/bin" + let toolPath = tcBinPath / command[0] + if try await fs.exists(atPath: toolPath) && toolPath.isLexicallyNormal { + executable = .path(toolPath) + } else { + executable = .name(command[0]) + } + } + + let processConfig = Configuration( + executable: executable, + arguments: Arguments([String](command[1...])), + environment: env + ) + + if let outputHandler = ctx.outputHandler { + let result = try await Subprocess.run(processConfig) { _, output in + for try await line in output.lines() { + await outputHandler.handleOutputLine(line.replacing("\n", with: "")) } - return } - try await Swiftly.currentPlatform.proxy(ctx, toolchain, command[0], [String](command[1...])) - } catch let terminated as RunProgramError { - if ctx.mockedHomeDir != nil { - throw terminated + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: processConfig) } - Foundation.exit(terminated.exitCode) - } catch { - throw error + + return + } + + let result = try await Subprocess.run(.path(FilePath(command[0])), arguments: Arguments([String](command[1...])), environment: env, input: .standardInput, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: processConfig) } } diff --git a/Sources/SwiftlyCore/Commands+Runnable+Output.swift b/Sources/SwiftlyCore/Commands+Runnable+Output.swift index 618596fb..a5577b9c 100644 --- a/Sources/SwiftlyCore/Commands+Runnable+Output.swift +++ b/Sources/SwiftlyCore/Commands+Runnable+Output.swift @@ -2,8 +2,8 @@ import Foundation import SystemPackage extension SystemCommand.dsclCommand.readCommand: Output { - public func properties(_ p: Platform) async throws -> [(key: String, value: String)] { - let output = try await self.output(p) + public func properties(_: Platform) async throws -> [(key: String, value: String)] { + let output = try await self.output(limit: 1024 * 100) guard let output else { return [] } var props: [(key: String, value: String)] = [] @@ -21,8 +21,8 @@ extension SystemCommand.lipoCommand.createCommand: Runnable {} extension SystemCommand.pkgbuildCommand: Runnable {} extension SystemCommand.getentCommand: Output { - public func entries(_ platform: Platform) async throws -> [[String]] { - let output = try await output(platform) + public func entries() async throws -> [[String]] { + let output = try await output(limit: 1024 * 100) guard let output else { return [] } var entries: [[String]] = [] diff --git a/Sources/SwiftlyCore/ModeledCommandLine.swift b/Sources/SwiftlyCore/ModeledCommandLine.swift index 10be5173..abd22aed 100644 --- a/Sources/SwiftlyCore/ModeledCommandLine.swift +++ b/Sources/SwiftlyCore/ModeledCommandLine.swift @@ -1,215 +1,132 @@ import Foundation +import Subprocess import SystemPackage -public enum CommandLineError: Error { - case invalidArgs - case errorExit(exitCode: Int32, program: String) - case unknownVersion -} - -// This section is a clone of the Configuration type from the new Subprocess package, until we can depend on that package. -public struct Configuration: Sendable { - /// The executable to run. - public var executable: Executable - /// The arguments to pass to the executable. - public var arguments: Arguments - /// The environment to use when running the executable. - public var environment: Environment - - public init(executable: Executable, arguments: Arguments, environment: Environment) { - self.executable = executable - self.arguments = arguments - self.environment = environment - } -} - -public struct Executable: Sendable, Hashable { - internal enum Storage: Sendable, Hashable { - case executable(String) - case path(FilePath) - } - - internal let storage: Storage - - private init(_config: Storage) { - self.storage = _config - } - - /// Locate the executable by its name. - /// `Subprocess` will use `PATH` value to - /// determine the full path to the executable. - public static func name(_ executableName: String) -> Self { - .init(_config: .executable(executableName)) - } - - /// Locate the executable by its full path. - /// `Subprocess` will use this path directly. - public static func path(_ filePath: FilePath) -> Self { - .init(_config: .path(filePath)) - } -} - -public struct Environment: Sendable, Hashable { - internal enum Configuration: Sendable, Hashable { - case inherit([String: String]) - case custom([String: String]) - } - - internal let config: Configuration - - init(config: Configuration) { - self.config = config - } - - /// Child process should inherit the same environment - /// values from its parent process. - public static var inherit: Self { - .init(config: .inherit([:])) - } - - /// Override the provided `newValue` in the existing `Environment` - public func updating(_ newValue: [String: String]) -> Self { - .init(config: .inherit(newValue)) - } - - /// Use custom environment variables - public static func custom(_ newValue: [String: String]) -> Self { - .init(config: .custom(newValue)) - } +public protocol Runnable { + func config() -> Configuration } -internal enum StringOrRawBytes: Sendable, Hashable { - case string(String) - - var stringValue: String? { - switch self { - case let .string(string): - return string +extension Runnable { + public func run< + Input: InputProtocol, + Output: OutputProtocol, + Error: ErrorOutputProtocol + >( + environment: Environment = .inherit, + input: Input = .none, + output: Output, + error: Error = .discarded + ) async throws -> CollectedResult { + var c = self.config() + + // TODO: someday the configuration might have its own environment from the modeled commands. That will require this to be able to merge the environment from the commands with the provided environment. + c.environment = environment + + let result = try await Subprocess.run(c, input: input, output: output, error: error) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) } - } - var description: String { - switch self { - case let .string(string): - return string - } + return result } - var count: Int { - switch self { - case let .string(string): - return string.count - } - } + public func run( + environment: Environment = .inherit, + quiet: Bool = false, + ) async throws { + var c = self.config() + + // TODO: someday the configuration might have its own environment from the modeled commands. That will require this to be able to merge the environment from the commands with the provided environment. + c.environment = environment - func hash(into hasher: inout Hasher) { - // If Raw bytes is valid UTF8, hash it as so - switch self { - case let .string(string): - hasher.combine(string) + if !quiet { + let result = try await Subprocess.run(c, input: .standardInput, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } + } else { + let result = try await Subprocess.run(c, input: .none, output: .discarded, error: .discarded) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } } } } -public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { - public typealias ArrayLiteralElement = String +public protocol Output: Runnable {} - internal let storage: [StringOrRawBytes] +extension Output { + public func output( + environment: Environment = .inherit, + limit: Int, + quiet: Bool = false + ) async throws -> String? { + var c = self.config() + + // TODO: someday the configuration might have its own environment from the modeled commands. That will require this to be able to merge the environment from the commands with the provided environment. + c.environment = environment + + if !quiet { + let result = try await Subprocess.run( + self.config(), + output: .string(limit: limit), + error: .standardError + ) + + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } - /// Create an Arguments object using the given literal values - public init(arrayLiteral elements: String...) { - self.storage = elements.map { .string($0) } - } + return result.standardOutput + } else { + let result = try await Subprocess.run( + self.config(), + output: .string(limit: limit), + error: .discarded + ) - /// Create an Arguments object using the given array - public init(_ array: [String]) { - self.storage = array.map { .string($0) } - } -} + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } -// Provide string representations of Configuration -extension Executable: CustomStringConvertible { - public var description: String { - switch self.storage { - case let .executable(name): - name - case let .path(path): - path.string + return result.standardOutput } } -} -extension Arguments: CustomStringConvertible { - public var description: String { - let normalized: [String] = self.storage.map(\.description).map { - $0.contains(" ") ? "\"\($0)\"" : String($0) - } - - return normalized.joined(separator: " ") - } -} + public func output( + environment: Environment = .inherit, + limit _: Int, + quiet: Bool = false, + body: (AsyncBufferSequence) -> Void + ) async throws { + var c = self.config() -extension Configuration: CustomStringConvertible { - public var description: String { - "\(self.executable) \(self.arguments)" - } -} + // TODO: someday the configuration might have its own environment from the modeled commands. That will require this to be able to merge the environment from the commands with the provided environment. + c.environment = environment -public protocol Runnable { - func config() -> Configuration -} - -extension Runnable { - public func run(_ p: Platform, env: [String: String] = ProcessInfo.processInfo.environment, quiet: Bool = false) async throws { - let c = self.config() - let executable = switch c.executable.storage { - case let .executable(name): - name - case let .path(p): - p.string - } - let args = c.arguments.storage.map(\.description) - - var newEnv: [String: String] = env - - switch c.environment.config { - case let .inherit(newValue): - for (key, value) in newValue { - newEnv[key] = value + if !quiet { + let result = try await Subprocess.run( + self.config(), + error: .standardError + ) { _, sequence in + body(sequence) } - case let .custom(newValue): - newEnv = newValue - } - - try p.runProgram([executable] + args, quiet: quiet, env: newEnv) - } -} -public protocol Output { - func config() -> Configuration -} + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } + } else { + let result = try await Subprocess.run( + self.config(), + error: .discarded + ) { _, sequence in + body(sequence) + } -// TODO: look into making this something that can be Decodable (i.e. streamable) -extension Output { - public func output(_ p: Platform) async throws -> String? { - let c = self.config() - let executable = switch c.executable.storage { - case let .executable(name): - name - case let .path(p): - p.string - } - let args = c.arguments.storage.map(\.description) - var env: [String: String] = ProcessInfo.processInfo.environment - switch c.environment.config { - case let .inherit(newValue): - for (key, value) in newValue { - env[key] = value + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) } - case let .custom(newValue): - env = newValue } - return try await p.runProgramOutput(executable, args, env: env) } } diff --git a/Sources/SwiftlyCore/Platform+Process.swift b/Sources/SwiftlyCore/Platform+Process.swift new file mode 100644 index 00000000..a92d894e --- /dev/null +++ b/Sources/SwiftlyCore/Platform+Process.swift @@ -0,0 +1,63 @@ +import Foundation +import Subprocess +#if os(macOS) +import System +#endif + +import SystemPackage + +extension Subprocess.Executable { +#if os(macOS) + public static func path(_ filePath: SystemPackage.FilePath) -> Self { + .path(System.FilePath(filePath.string)) + } +#endif +} + +extension Platform { +#if os(macOS) || os(Linux) + public func proxyEnvironment(_ ctx: SwiftlyCoreContext, env: Environment, toolchain: ToolchainVersion) async throws -> Environment { + var environment = env + + let tcPath = try await self.findToolchainLocation(ctx, toolchain) / "usr/bin" + guard try await fs.exists(atPath: tcPath) else { + throw SwiftlyError( + message: + "Toolchain \(toolchain) could not be located in \(tcPath). You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." + ) + } + + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + var pathComponents = path.split(separator: ":").map { String($0) } + + // The toolchain goes to the beginning of the PATH + pathComponents.removeAll(where: { $0 == tcPath.string }) + pathComponents = [tcPath.string] + pathComponents + + // Remove swiftly bin directory from the PATH entirely + let swiftlyBinDir = self.swiftlyBinDir(ctx) + pathComponents.removeAll(where: { $0 == swiftlyBinDir.string }) + + environment = environment.updating(["PATH": String(pathComponents.joined(separator: ":"))]) + +#if os(macOS) + // On macOS, we try to set SDKROOT if its empty for tools like clang++ that need it to + // find standard libraries that aren't in the toolchain, like libc++. Here we + // use xcrun to tell us what the default sdk root should be. + if ProcessInfo.processInfo.environment["SDKROOT"] == nil { + environment = environment.updating([ + "SDKROOT": try? await run( + .path(SystemPackage.FilePath("/usr/bin/xcrun")), + arguments: ["--show-sdk-path"], + output: .string(limit: 1024 * 10) + ).standardOutput?.replacingOccurrences(of: "\n", with: ""), + ] + ) + } +#endif + + return environment + } + +#endif +} diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index e4782fc3..47c886da 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess import SystemPackage public struct PlatformDefinition: Codable, Equatable, Sendable { @@ -49,9 +50,13 @@ public struct PlatformDefinition: Codable, Equatable, Sendable { } public struct RunProgramError: Swift.Error { - public let exitCode: Int32 - public let program: String - public let arguments: [String] + public let terminationStatus: TerminationStatus + public let config: Configuration + + public init(terminationStatus: TerminationStatus, config: Configuration) { + self.terminationStatus = terminationStatus + self.config = config + } } public protocol Platform: Sendable { @@ -161,192 +166,6 @@ extension Platform { } #if os(macOS) || os(Linux) - func proxyEnv(_ ctx: SwiftlyCoreContext, env: [String: String], toolchain: ToolchainVersion) async throws -> [String: String] { - var newEnv = env - - let tcPath = try await self.findToolchainLocation(ctx, toolchain) / "usr/bin" - guard try await fs.exists(atPath: tcPath) else { - throw SwiftlyError( - message: - "Toolchain \(toolchain) could not be located in \(tcPath). You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." - ) - } - - var pathComponents = (newEnv["PATH"] ?? "").split(separator: ":").map { String($0) } - - // The toolchain goes to the beginning of the PATH - pathComponents.removeAll(where: { $0 == tcPath.string }) - pathComponents = [tcPath.string] + pathComponents - - // Remove swiftly bin directory from the PATH entirely - let swiftlyBinDir = self.swiftlyBinDir(ctx) - pathComponents.removeAll(where: { $0 == swiftlyBinDir.string }) - - newEnv["PATH"] = String(pathComponents.joined(separator: ":")) - - return newEnv - } - - /// Proxy the invocation of the provided command to the chosen toolchain. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func proxy(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String], _ env: [String: String] = [:]) async throws { - let tcPath = (try await self.findToolchainLocation(ctx, toolchain)) / "usr/bin" - - let commandTcPath = tcPath / command - let commandToRun = if try await fs.exists(atPath: commandTcPath) { - commandTcPath.string - } else { - command - } - - var newEnv = try await self.proxyEnv(ctx, env: ProcessInfo.processInfo.environment, toolchain: toolchain) - for (key, value) in env { - newEnv[key] = value - } - -#if os(macOS) - // On macOS, we try to set SDKROOT if its empty for tools like clang++ that need it to - // find standard libraries that aren't in the toolchain, like libc++. Here we - // use xcrun to tell us what the default sdk root should be. - if newEnv["SDKROOT"] == nil { - newEnv["SDKROOT"] = (try? await self.runProgramOutput("/usr/bin/xcrun", "--show-sdk-path"))?.replacingOccurrences(of: "\n", with: "") - } -#endif - - try self.runProgram([commandToRun] + arguments, env: newEnv) - } - - /// Proxy the invocation of the provided command to the chosen toolchain and capture the output. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func proxyOutput(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String]) async throws -> String? { - let tcPath = (try await self.findToolchainLocation(ctx, toolchain)) / "usr/bin" - - let commandTcPath = tcPath / command - let commandToRun = if try await fs.exists(atPath: commandTcPath) { - commandTcPath.string - } else { - command - } - - return try await self.runProgramOutput(commandToRun, arguments, env: self.proxyEnv(ctx, env: ProcessInfo.processInfo.environment, toolchain: toolchain)) - } - - /// Run a program. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func runProgram(_ args: String..., quiet: Bool = false, env: [String: String]? = nil) - throws - { - try self.runProgram([String](args), quiet: quiet, env: env) - } - - /// Run a program. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) - throws - { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = args - - if let env { - process.environment = env - } - - if quiet { - process.standardOutput = nil - process.standardError = nil - } - - try process.run() - // Attach this process to our process group so that Ctrl-C and other signals work - let pgid = tcgetpgrp(STDOUT_FILENO) - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, process.processIdentifier) - } - - defer { - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - } - } - - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw RunProgramError(exitCode: process.terminationStatus, program: args.first!, arguments: Array(args.dropFirst())) - } - } - - /// Run a program and capture its output. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func runProgramOutput(_ program: String, _ args: String..., env: [String: String]? = nil) - async throws -> String? - { - try await self.runProgramOutput(program, [String](args), env: env) - } - - /// Run a program and capture its output. - /// - /// In the case where the command exit with a non-zero exit code a RunProgramError is thrown with - /// the exit code and program information. - /// - public func runProgramOutput(_ program: String, _ args: [String], env: [String: String]? = nil) - async throws -> String? - { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [program] + args - - if let env { - process.environment = env - } - - let outPipe = Pipe() - process.standardInput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - process.standardOutput = outPipe - - try process.run() - // Attach this process to our process group so that Ctrl-C and other signals work - let pgid = tcgetpgrp(STDOUT_FILENO) - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, process.processIdentifier) - } - defer { - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - } - } - - let outData = try outPipe.fileHandleForReading.readToEnd() - - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw RunProgramError(exitCode: process.terminationStatus, program: program, arguments: args) - } - - if let outData { - return String(data: outData, encoding: .utf8) - } else { - return nil - } - } // Install ourselves in the final location public func installSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws { diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index a2dc9692..cd6f7ee5 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Subprocess import SwiftlyCore import SystemPackage @@ -96,18 +97,18 @@ struct TestSwiftly: AsyncParsableCommand { print("Extracting swiftly release") #if os(Linux) - try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchiveFile)).run(currentPlatform, quiet: false) + try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchiveFile)).run() #elseif os(macOS) - try await sys.installer(.verbose, .pkg(swiftlyArchiveFile), .target("CurrentUserHomeDirectory")).run(currentPlatform, quiet: false) + try await sys.installer(.verbose, .pkg(swiftlyArchiveFile), .target("CurrentUserHomeDirectory")).run() #endif #if os(Linux) let extractedSwiftly = FilePath("./swiftly") #elseif os(macOS) - let extractedSwiftly = fs.home / ".swiftly/bin/swiftly" + let extractedSwiftly = FilePath((fs.home / ".swiftly/bin/swiftly").string) #endif - var env = ProcessInfo.processInfo.environment + var env: Environment = .inherit let shell = FilePath(try await currentPlatform.getShell()) var customLoc: FilePath? @@ -115,32 +116,55 @@ struct TestSwiftly: AsyncParsableCommand { customLoc = fs.mktemp() print("Installing swiftly to custom location \(customLoc!)") - env["SWIFTLY_HOME_DIR"] = customLoc!.string - env["SWIFTLY_BIN_DIR"] = (customLoc! / "bin").string - env["SWIFTLY_TOOLCHAINS_DIR"] = (customLoc! / "toolchains").string - try currentPlatform.runProgram(extractedSwiftly.string, "init", "--assume-yes", "--no-modify-profile", "--skip-install", quiet: false, env: env) - try await sh(executable: .path(shell), .login, .command(". \"\(customLoc! / "env.sh")\" && swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(currentPlatform, env: env, quiet: false) + env = env.updating([ + "SWIFTLY_HOME_DIR": customLoc!.string, + "SWIFTLY_BIN_DIR": (customLoc! / "bin").string, + "SWIFTLY_TOOLCHAINS_DIR": (customLoc! / "toolchains").string, + ]) + + let config = Configuration( + .path(extractedSwiftly), + arguments: ["init", "--assume-yes", "--no-modify-profile", "--skip-install"], + environment: env + ) + let result = try await Subprocess.run(config, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: config) + } + try await sh(executable: .path(shell), .login, .command(". \"\(customLoc! / "env.sh")\" && swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(environment: env, quiet: false) } else { print("Installing swiftly to the default location.") // Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell if shell.ends(with: "bash") { - env["BASH_ENV"] = (fs.home / ".profile").string + env = env.updating(["BASH_ENV": (fs.home / ".profile").string]) } else if shell.ends(with: "zsh") { - env["ZDOTDIR"] = fs.home.string + env = env.updating(["ZDOTDIR": fs.home.string]) } else if shell.ends(with: "fish") { - env["XDG_CONFIG_HOME"] = (fs.home / ".config").string + env = env.updating(["XDG_CONFIG_HOME": (fs.home / ".config").string]) } - try currentPlatform.runProgram(extractedSwiftly.string, "init", "--assume-yes", "--skip-install", quiet: false, env: env) - try await sh(executable: .path(shell), .login, .command("swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(currentPlatform, env: env, quiet: false) + let config = Configuration( + .path(extractedSwiftly), + arguments: ["init", "--assume-yes", "--skip-install"], + environment: env + ) + let result = try await Subprocess.run(config, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: config) + } + try await sh(executable: .path(shell), .login, .command("swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(environment: env) } var swiftReady = false if NSUserName() == "root" { if try await fs.exists(atPath: "./post-install.sh") { - try currentPlatform.runProgram(shell.string, "./post-install.sh", quiet: false) + let config = Configuration(.path(shell), arguments: ["./post-install.sh"]) + let result = try await Subprocess.run(config, input: .standardInput, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: config) + } } swiftReady = true } else if try await fs.exists(atPath: "./post-install.sh") { @@ -150,9 +174,9 @@ struct TestSwiftly: AsyncParsableCommand { } if let customLoc = customLoc, swiftReady { - try await sh(executable: .path(shell), .login, .command(". \"\(customLoc / "env.sh")\" && swift --version")).run(currentPlatform, env: env, quiet: false) + try await sh(executable: .path(shell), .login, .command(". \"\(customLoc / "env.sh")\" && swift --version")).run(environment: env) } else if swiftReady { - try await sh(executable: .path(shell), .login, .command("swift --version")).run(currentPlatform, env: env, quiet: false) + try await sh(executable: .path(shell), .login, .command("swift --version")).run(environment: env) } // Test self-uninstall functionality @@ -160,16 +184,16 @@ struct TestSwiftly: AsyncParsableCommand { try await self.testSelfUninstall(customLoc: customLoc, shell: shell, env: env) } - private func testSelfUninstall(customLoc: FilePath?, shell: FilePath, env: [String: String]) async throws { + private func testSelfUninstall(customLoc: FilePath?, shell: FilePath, env: Environment) async throws { if let customLoc = customLoc { // Test self-uninstall for custom location - try await sh(executable: .path(shell), .login, .command(". \"\(customLoc / "env.sh")\" && swiftly self-uninstall --assume-yes")).run(currentPlatform, env: env, quiet: false) + try await sh(executable: .path(shell), .login, .command(". \"\(customLoc / "env.sh")\" && swiftly self-uninstall --assume-yes")).run(environment: env) // Verify cleanup for custom location try await self.verifyCustomLocationCleanup(customLoc: customLoc) } else { // Test self-uninstall for default location - try await sh(executable: .path(shell), .login, .command("swiftly self-uninstall --assume-yes")).run(currentPlatform, env: env, quiet: false) + try await sh(executable: .path(shell), .login, .command("swiftly self-uninstall --assume-yes")).run(environment: env) // Verify cleanup for default location try await self.verifyDefaultLocationCleanup(shell: shell, env: env) @@ -204,7 +228,7 @@ struct TestSwiftly: AsyncParsableCommand { print("✓ Custom location cleanup verification passed") } - private func verifyDefaultLocationCleanup(shell: FilePath, env: [String: String]) async throws { + private func verifyDefaultLocationCleanup(shell: FilePath, env: Environment) async throws { print("Verifying cleanup for default location") let swiftlyHome = fs.home / ".swiftly" @@ -237,7 +261,7 @@ struct TestSwiftly: AsyncParsableCommand { // Verify swiftly command is no longer available do { - try await sh(executable: .path(shell), .login, .command("which swiftly")).run(currentPlatform, env: env, quiet: true) + try await sh(executable: .path(shell), .login, .command("which swiftly")).run(environment: env) throw TestError("swiftly command is still available in PATH after uninstall") } catch { // Expected - swiftly should not be found diff --git a/Tests/SwiftlyTests/CommandLineTests.swift b/Tests/SwiftlyTests/CommandLineTests.swift index 8f33bfac..94d14d2b 100644 --- a/Tests/SwiftlyTests/CommandLineTests.swift +++ b/Tests/SwiftlyTests/CommandLineTests.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess @testable import Swiftly @testable import SwiftlyCore import SystemPackage @@ -9,13 +10,17 @@ public typealias sys = SystemCommand @Suite public struct CommandLineTests { @Test func testDsclModel() { - var config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), key: ["UserShell"]).config() + var cmd = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), key: ["UserShell"]) + var config = cmd.config() + var args = cmd.commandArgs() #expect(config.executable == .name("dscl")) - #expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell"]) + #expect(args == [".", "-read", "/Users/swiftly", "UserShell"]) - config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), key: ["UserShell", "Picture"]).config() + cmd = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), key: ["UserShell", "Picture"]) + config = cmd.config() + args = cmd.commandArgs() #expect(config.executable == .name("dscl")) - #expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell", "Picture"]) + #expect(args == [".", "-read", "/Users/swiftly", "UserShell", "Picture"]) } @Test( @@ -26,58 +31,101 @@ public struct CommandLineTests { ) func testDscl() async throws { let properties = try await sys.dscl(datasource: ".").read(path: fs.home, key: ["UserShell"]).properties(Swiftly.currentPlatform) - #expect(properties.count == 1) // Only one shell for the current user + + guard properties.count == 1 else { + Issue.record("Unexpected number of properties. There is only one shell for the current user.") + return + } + #expect(properties[0].key == "UserShell") // The one property key should be the one that is requested } @Test func testLipo() { - var config = sys.lipo(input_file: "swiftly1", "swiftly2").create(.output("swiftly-universal")).config() + var cmd = sys.lipo(input_file: "swiftly1", "swiftly2").create(.output("swiftly-universal")) + var config = cmd.config() + var args = cmd.commandArgs() #expect(config.executable == .name("lipo")) - #expect(config.arguments.storage.map(\.description) == ["swiftly1", "swiftly2", "-create", "-output", "swiftly-universal"]) + #expect(args == ["swiftly1", "swiftly2", "-create", "-output", "swiftly-universal"]) + + cmd = sys.lipo(input_file: "swiftly").create(.output("swiftly-universal-with-one-arch")) + config = cmd.config() + args = cmd.commandArgs() - config = sys.lipo(input_file: "swiftly").create(.output("swiftly-universal-with-one-arch")).config() #expect(config.executable == .name("lipo")) - #expect(config.arguments.storage.map(\.description) == ["swiftly", "-create", "-output", "swiftly-universal-with-one-arch"]) + #expect(args == ["swiftly", "-create", "-output", "swiftly-universal-with-one-arch"]) } @Test func testPkgbuild() { - var config = sys.pkgbuild(.root("mypath"), package_output_path: "outputDir").config() - #expect(String(describing: config) == "pkgbuild --root mypath outputDir") - - config = sys.pkgbuild(.version("1234"), .root("somepath"), package_output_path: "output").config() - #expect(String(describing: config) == "pkgbuild --version 1234 --root somepath output") - - config = sys.pkgbuild(.install_location("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), .sign("mycert"), .root("someroot"), package_output_path: "my.pkg").config() - #expect(String(describing: config) == "pkgbuild --install-location /usr/local --version 1.0.0 --identifier org.foo.bar --sign mycert --root someroot my.pkg") - - config = sys.pkgbuild(.install_location("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), .root("someroot"), package_output_path: "my.pkg").config() - #expect(String(describing: config) == "pkgbuild --install-location /usr/local --version 1.0.0 --identifier org.foo.bar --root someroot my.pkg") + var cmd = sys.pkgbuild(.root("mypath"), package_output_path: "outputDir") + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("pkgbuild")) + #expect(args == ["--root", "mypath", "outputDir"]) + + cmd = sys.pkgbuild(.version("1234"), .root("somepath"), package_output_path: "output") + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("pkgbuild")) + #expect(args == ["--version", "1234", "--root", "somepath", "output"]) + + cmd = sys.pkgbuild(.install_location("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), .sign("mycert"), .root("someroot"), package_output_path: "my.pkg") + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("pkgbuild")) + #expect(args == ["--install-location", "/usr/local", "--version", "1.0.0", "--identifier", "org.foo.bar", "--sign", "mycert", "--root", "someroot", "my.pkg"]) + + cmd = sys.pkgbuild(.install_location("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), .root("someroot"), package_output_path: "my.pkg") + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("pkgbuild")) + #expect(args == ["--install-location", "/usr/local", "--version", "1.0.0", "--identifier", "org.foo.bar", "--root", "someroot", "my.pkg"]) } @Test func testGetent() { - var config = sys.getent(database: "passwd", key: "swiftly").config() - #expect(String(describing: config) == "getent passwd swiftly") - - config = sys.getent(database: "foo", key: "abc", "def").config() - #expect(String(describing: config) == "getent foo abc def") + var cmd = sys.getent(database: "passwd", key: "swiftly") + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("getent")) + #expect(args == ["passwd", "swiftly"]) + + cmd = sys.getent(database: "foo", key: "abc", "def") + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("getent")) + #expect(args == ["foo", "abc", "def"]) } @Test func testGitModel() { - var config = sys.git().log(.max_count("1"), .pretty("format:%d")).config() - #expect(String(describing: config) == "git log --max-count 1 --pretty format:%d") - - config = sys.git().log().config() - #expect(String(describing: config) == "git log") - - config = sys.git().log(.pretty("foo")).config() - #expect(String(describing: config) == "git log --pretty foo") - - config = sys.git().diffindex(.quiet, tree_ish: "HEAD").config() - #expect(String(describing: config) == "git diff-index --quiet HEAD") - - config = sys.git().diffindex(tree_ish: "main").config() - #expect(String(describing: config) == "git diff-index main") + var cmd = sys.git().log(.max_count("1"), .pretty("format:%d")) + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("git")) + #expect(args == ["log", "--max-count", "1", "--pretty", "format:%d"]) + + cmd = sys.git().log() + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("git")) + #expect(args == ["log"]) + + cmd = sys.git().log(.pretty("foo")) + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("git")) + #expect(args == ["log", "--pretty", "foo"]) + + var indexCmd = sys.git().diffindex(.quiet, tree_ish: "HEAD") + config = indexCmd.config() + args = indexCmd.commandArgs() + #expect(config.executable == .name("git")) + #expect(args == ["diff-index", "--quiet", "HEAD"]) + + indexCmd = sys.git().diffindex(tree_ish: "main") + config = indexCmd.config() + args = indexCmd.commandArgs() + #expect(config.executable == .name("git")) + #expect(args == ["diff-index", "main"]) } @Test( @@ -90,18 +138,18 @@ public struct CommandLineTests { // GIVEN a simple git repository let tmp = fs.mktemp() try await fs.mkdir(atPath: tmp) - try await sys.git(.workingDir(tmp))._init().run(Swiftly.currentPlatform) + try await sys.git(.workingDir(tmp))._init().run() // AND a simple history try "Some text".write(to: tmp / "foo.txt", atomically: true) - try await Swiftly.currentPlatform.runProgram("git", "-C", "\(tmp)", "add", "foo.txt") - try await Swiftly.currentPlatform.runProgram("git", "-C", "\(tmp)", "config", "--local", "user.email", "user@example.com") - try await Swiftly.currentPlatform.runProgram("git", "-C", "\(tmp)", "config", "--local", "commit.gpgsign", "false") - try await sys.git(.workingDir(tmp)).commit(.message("Initial commit")).run(Swiftly.currentPlatform) - try await sys.git(.workingDir(tmp)).diffindex(.quiet, tree_ish: "HEAD").run(Swiftly.currentPlatform) + try #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "add", "foo.txt"], output: .standardOutput).terminationStatus.isSuccess) + try #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "user.email", "user@example.com"], output: .standardOutput).terminationStatus.isSuccess) + try #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "commit.gpgsign", "false"], output: .standardOutput).terminationStatus.isSuccess) + try await sys.git(.workingDir(tmp)).commit(.message("Initial commit")).run() + try await sys.git(.workingDir(tmp)).diffindex(.quiet, tree_ish: "HEAD").run() // WHEN inspecting the log - let log = try await sys.git(.workingDir(tmp)).log(.max_count("1")).output(Swiftly.currentPlatform)! + let log = try await sys.git(.workingDir(tmp)).log(.max_count("1")).output(limit: 1024 * 10)! // THEN it is not empty #expect(log != "") @@ -109,23 +157,35 @@ public struct CommandLineTests { try "Some new text".write(to: tmp / "foo.txt", atomically: true) // THEN diff index finds a change - try await #expect(throws: Error.self) { - try await sys.git(.workingDir(tmp)).diffindex(.quiet, tree_ish: "HEAD").run(Swiftly.currentPlatform) + await #expect(throws: Error.self) { + try await sys.git(.workingDir(tmp)).diffindex(.quiet, tree_ish: "HEAD").run() } } @Test func testTarModel() { - var config = sys.tar(.directory("/some/cool/stuff")).create(.compressed, .archive("abc.tgz"), files: ["a", "b"]).config() - #expect(String(describing: config) == "tar -C /some/cool/stuff --create -z --file abc.tgz a b") - - config = sys.tar().create(.archive("myarchive.tar"), files: nil).config() - #expect(String(describing: config) == "tar --create --file myarchive.tar") - - config = sys.tar(.directory("/this/is/the/place")).extract(.compressed, .archive("def.tgz")).config() - #expect(String(describing: config) == "tar -C /this/is/the/place --extract -z --file def.tgz") - - config = sys.tar().extract(.archive("somearchive.tar")).config() - #expect(String(describing: config) == "tar --extract --file somearchive.tar") + var cmd = sys.tar(.directory("/some/cool/stuff")).create(.compressed, .archive("abc.tgz"), files: ["a", "b"]) + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("tar")) + #expect(args == ["-C", "/some/cool/stuff", "--create", "-z", "--file", "abc.tgz", "a", "b"]) + + cmd = sys.tar().create(.archive("myarchive.tar"), files: nil) + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("tar")) + #expect(args == ["--create", "--file", "myarchive.tar"]) + + var extractCmd = sys.tar(.directory("/this/is/the/place")).extract(.compressed, .archive("def.tgz")) + config = extractCmd.config() + args = extractCmd.commandArgs() + #expect(config.executable == .name("tar")) + #expect(args == ["-C", "/this/is/the/place", "--extract", "-z", "--file", "def.tgz"]) + + extractCmd = sys.tar().extract(.archive("somearchive.tar")) + config = extractCmd.config() + args = extractCmd.commandArgs() + #expect(config.executable == .name("tar")) + #expect(args == ["--extract", "--file", "somearchive.tar"]) } @Test( @@ -138,49 +198,67 @@ public struct CommandLineTests { let tmp = fs.mktemp() try await fs.mkdir(atPath: tmp) let readme = "README.md" - try await "README".write(to: tmp / readme, atomically: true) + try "README".write(to: tmp / readme, atomically: true) let arch = fs.mktemp(ext: "tar") let archCompressed = fs.mktemp(ext: "tgz") - try await sys.tar(.directory(tmp)).create(.verbose, .archive(arch), files: [FilePath(readme)]).run(Swiftly.currentPlatform) - try await sys.tar(.directory(tmp)).create(.verbose, .compressed, .archive(archCompressed), files: [FilePath(readme)]).run(Swiftly.currentPlatform) + try await sys.tar(.directory(tmp)).create(.verbose, .archive(arch), files: [FilePath(readme)]).run() + try await sys.tar(.directory(tmp)).create(.verbose, .compressed, .archive(archCompressed), files: [FilePath(readme)]).run() let tmp2 = fs.mktemp() try await fs.mkdir(atPath: tmp2) - try await sys.tar(.directory(tmp2)).extract(.verbose, .archive(arch)).run(Swiftly.currentPlatform) + try await sys.tar(.directory(tmp2)).extract(.verbose, .archive(arch)).run() - let contents = try await String(contentsOf: tmp2 / readme, encoding: .utf8) + let contents = try String(contentsOf: tmp2 / readme, encoding: .utf8) #expect(contents == "README") let tmp3 = fs.mktemp() try await fs.mkdir(atPath: tmp3) - try await sys.tar(.directory(tmp3)).extract(.verbose, .compressed, .archive(archCompressed)).run(Swiftly.currentPlatform) + try await sys.tar(.directory(tmp3)).extract(.verbose, .compressed, .archive(archCompressed)).run() - let contents2 = try await String(contentsOf: tmp3 / readme, encoding: .utf8) + let contents2 = try String(contentsOf: tmp3 / readme, encoding: .utf8) #expect(contents2 == "README") } @Test func testSwiftModel() async throws { - var config = sys.swift().package().reset().config() - #expect(String(describing: config) == "swift package reset") - - config = sys.swift().package().clean().config() - #expect(String(describing: config) == "swift package clean") - - config = sys.swift().sdk().install(.checksum("deadbeef"), bundle_path_or_url: "path/to/bundle").config() - #expect(String(describing: config) == "swift sdk install --checksum deadbeef path/to/bundle") - - config = sys.swift().sdk().remove([], sdk_id_or_bundle_name: "some.bundle").config() - #expect(String(describing: config) == "swift sdk remove some.bundle") - - config = sys.swift().build(.arch("x86_64"), .configuration("release"), .pkg_config_path("path/to/pc"), .swift_sdk("sdk.id"), .static_swift_stdlib, .product("product1")).config() - #expect(String(describing: config) == "swift build --arch x86_64 --configuration release --pkg-config-path path/to/pc --swift-sdk sdk.id --static-swift-stdlib --product product1") - - config = sys.swift().build().config() - #expect(String(describing: config) == "swift build") + let cmd = sys.swift().package().reset() + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["package", "reset"]) + + let cleanCmd = sys.swift().package().clean() + config = cleanCmd.config() + args = cleanCmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["package", "clean"]) + + let installCmd = sys.swift().sdk().install(.checksum("deadbeef"), bundle_path_or_url: "path/to/bundle") + config = installCmd.config() + args = installCmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["sdk", "install", "--checksum", "deadbeef", "path/to/bundle"]) + + let removeCmd = sys.swift().sdk().remove([], sdk_id_or_bundle_name: "some.bundle") + config = removeCmd.config() + args = removeCmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["sdk", "remove", "some.bundle"]) + + var buildCmd = sys.swift().build(.arch("x86_64"), .configuration("release"), .pkg_config_path("path/to/pc"), .swift_sdk("sdk.id"), .static_swift_stdlib, .product("product1")) + config = buildCmd.config() + args = buildCmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["build", "--arch", "x86_64", "--configuration", "release", "--pkg-config-path", "path/to/pc", "--swift-sdk", "sdk.id", "--static-swift-stdlib", "--product", "product1"]) + + buildCmd = sys.swift().build() + config = buildCmd.config() + args = buildCmd.commandArgs() + #expect(config.executable == .name("swift")) + #expect(args == ["build"]) } @Test( @@ -192,57 +270,93 @@ public struct CommandLineTests { func testSwift() async throws { let tmp = fs.mktemp() try await fs.mkdir(atPath: tmp) - try await sys.swift().package()._init(.package_path(tmp), .type("executable")).run(Swiftly.currentPlatform) - try await sys.swift().build(.package_path(tmp), .configuration("release")) + try await sys.swift().package()._init(.package_path(tmp), .type("executable")).run() + try await sys.swift().build(.package_path(tmp), .configuration("release")).run() } @Test func testMake() async throws { - var config = sys.make().install().config() - #expect(String(describing: config) == "make install") + let cmd = sys.make().install() + let config = cmd.config() + let args = cmd.commandArgs() + #expect(config.executable == .name("make")) + #expect(args == ["install"]) } @Test func testStrip() async throws { - var config = sys.strip(name: FilePath("foo")).config() - #expect(String(describing: config) == "strip foo") + let cmd = sys.strip(name: FilePath("foo")) + let config = cmd.config() + let args = cmd.commandArgs() + #expect(config.executable == .name("strip")) + #expect(args == ["foo"]) } @Test func testSha256Sum() async throws { - var config = sys.sha256sum(files: FilePath("abcde")).config() - #expect(String(describing: config) == "sha256sum abcde") + let cmd = sys.sha256sum(files: FilePath("abcde")) + let config = cmd.config() + let args = cmd.commandArgs() + #expect(config.executable == .name("sha256sum")) + #expect(args == ["abcde"]) } @Test func testProductBuild() async throws { - var config = sys.productbuild(.synthesize, .pkg_path(FilePath("mypkg")), output_path: FilePath("distribution")).config() - #expect(String(describing: config) == "productbuild --synthesize --package mypkg distribution") - - config = sys.productbuild(.dist_path(FilePath("mydist")), output_path: FilePath("product")).config() - #expect(String(describing: config) == "productbuild --distribution mydist product") - - config = sys.productbuild(.dist_path(FilePath("mydist")), .search_path(FilePath("pkgpath")), .cert("mycert"), output_path: FilePath("myproduct")).config() - #expect(String(describing: config) == "productbuild --distribution mydist --package-path pkgpath --sign mycert myproduct") + var cmd = sys.productbuild(.synthesize, .pkg_path(FilePath("mypkg")), output_path: FilePath("distribution")) + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("productbuild")) + #expect(args == ["--synthesize", "--package", "mypkg", "distribution"]) + + cmd = sys.productbuild(.dist_path(FilePath("mydist")), output_path: FilePath("product")) + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("productbuild")) + #expect(args == ["--distribution", "mydist", "product"]) + + cmd = sys.productbuild(.dist_path(FilePath("mydist")), .search_path(FilePath("pkgpath")), .cert("mycert"), output_path: FilePath("myproduct")) + config = cmd.config() + args = cmd.commandArgs() + #expect(config.executable == .name("productbuild")) + #expect(args == ["--distribution", "mydist", "--package-path", "pkgpath", "--sign", "mycert", "myproduct"]) } @Test func testGpg() async throws { - var config = sys.gpg()._import(key: FilePath("somekeys.asc")).config() - #expect(String(describing: config) == "gpg --import somekeys.asc") - - config = sys.gpg().verify(detached_signature: FilePath("file.sig"), signed_data: FilePath("file")).config() - #expect(String(describing: config) == "gpg --verify file.sig file") + let cmd = sys.gpg()._import(key: FilePath("somekeys.asc")) + var config = cmd.config() + var args = cmd.commandArgs() + #expect(config.executable == .name("gpg")) + #expect(args == ["--import", "somekeys.asc"]) + + let verifyCmd = sys.gpg().verify(detached_signature: FilePath("file.sig"), signed_data: FilePath("file")) + config = verifyCmd.config() + args = verifyCmd.commandArgs() + #expect(config.executable == .name("gpg")) + #expect(args == ["--verify", "file.sig", "file"]) } @Test func testPkgutil() async throws { - var config = sys.pkgutil(.verbose).checksignature(pkg_path: FilePath("path/to/my.pkg")).config() - #expect(String(describing: config) == "pkgutil --verbose --check-signature path/to/my.pkg") - - config = sys.pkgutil(.verbose).expand(pkg_path: FilePath("path/to/my.pkg"), dir_path: FilePath("expand/to/here")).config() - #expect(String(describing: config) == "pkgutil --verbose --expand path/to/my.pkg expand/to/here") - - config = sys.pkgutil(.volume("/Users/foo")).forget(pkg_id: "com.example.pkg").config() - #expect(String(describing: config) == "pkgutil --volume /Users/foo --forget com.example.pkg") + let checkSigCmd = sys.pkgutil(.verbose).checksignature(pkg_path: FilePath("path/to/my.pkg")) + var config = checkSigCmd.config() + var args = checkSigCmd.commandArgs() + #expect(config.executable == .name("pkgutil")) + #expect(args == ["--verbose", "--check-signature", "path/to/my.pkg"]) + + let expandCmd = sys.pkgutil(.verbose).expand(pkg_path: FilePath("path/to/my.pkg"), dir_path: FilePath("expand/to/here")) + config = expandCmd.config() + args = expandCmd.commandArgs() + #expect(config.executable == .name("pkgutil")) + #expect(args == ["--verbose", "--expand", "path/to/my.pkg", "expand/to/here"]) + + let forgetCmd = sys.pkgutil(.volume("/Users/foo")).forget(pkg_id: "com.example.pkg") + config = forgetCmd.config() + args = forgetCmd.commandArgs() + #expect(config.executable == .name("pkgutil")) + #expect(args == ["--volume", "/Users/foo", "--forget", "com.example.pkg"]) } @Test func testInstaller() async throws { - var config = sys.installer(.verbose, .pkg(FilePath("path/to/my.pkg")), .target("CurrentUserHomeDirectory")).config() - #expect(String(describing: config) == "installer -verbose -pkg path/to/my.pkg -target CurrentUserHomeDirectory") + let cmd = sys.installer(.verbose, .pkg(FilePath("path/to/my.pkg")), .target("CurrentUserHomeDirectory")) + let config = cmd.config() + let args = cmd.commandArgs() + #expect(config.executable == .name("installer")) + #expect(args == ["-verbose", "-pkg", "path/to/my.pkg", "-target", "CurrentUserHomeDirectory"]) } } diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 0fa3a8a2..9715242b 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -1,5 +1,6 @@ import AsyncHTTPClient import Foundation +import Subprocess @testable import Swiftly @testable import SwiftlyCore import SwiftlyWebsiteAPI @@ -170,9 +171,7 @@ private func withGpg(_ body: ((Runnable) async throws -> Void) async throws -> V try await fs.mkdir(.parents, atPath: gpgHome) try await fs.withTemporary(files: gpgHome) { func runGpg(_ runnable: Runnable) async throws { - var env = ProcessInfo.processInfo.environment - env["GNUPGHOME"] = gpgHome.string - try await runnable.run(Swiftly.currentPlatform, env: env, quiet: false) + try await runnable.run(environment: .inherit.updating(["GNUPGHOME": gpgHome.string]), quiet: false) } try await body(runGpg) diff --git a/Tests/SwiftlyTests/PlatformTests.swift b/Tests/SwiftlyTests/PlatformTests.swift index 8b3c5a8b..70621adc 100644 --- a/Tests/SwiftlyTests/PlatformTests.swift +++ b/Tests/SwiftlyTests/PlatformTests.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess @testable import Swiftly @testable import SwiftlyCore import SystemPackage @@ -91,7 +92,7 @@ import Testing @Test(.mockedSwiftlyVersion(), .testHome()) func findXcodeToolchainLocation() async throws { // GIVEN: the xcode toolchain // AND there is xcode installed - guard let swiftLocation = try? await Swiftly.currentPlatform.runProgramOutput("xcrun", "-f", "swift"), swiftLocation != "" else { + guard let swiftLocation = try? await run(.name("xcrun"), arguments: ["-f", "swift"], output: .string(limit: 1024 * 10)).standardOutput, swiftLocation != "" else { return } @@ -116,17 +117,18 @@ import Testing ] ) func proxyEnv(_ path: String) async throws { // GIVEN: a PATH that may contain the swiftly bin directory - let env = ["PATH": path.replacing("SWIFTLY_BIN_DIR", with: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)] + let env: Environment = .custom(["PATH": path.replacing("SWIFTLY_BIN_DIR", with: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)]) // WHEN: proxying to an installed toolchain - let newEnv = try await Swiftly.currentPlatform.proxyEnv(SwiftlyTests.ctx, env: env, toolchain: .newStable) + let newEnv = try await Swiftly.currentPlatform.proxyEnvironment(SwiftlyTests.ctx, env: env, toolchain: .newStable) // THEN: the toolchain's bin directory is added to the beginning of the PATH - #expect(newEnv["PATH"]!.hasPrefix(((try await Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, .newStable)) / "usr/bin").string)) + // #expect(newEnv.description == "") + #expect(newEnv.description.contains("PATH: \"\((try await Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, .newStable)) / "usr/bin"):")) // AND: the swiftly bin directory is removed from the PATH - #expect(!newEnv["PATH"]!.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) - #expect(!newEnv["PATH"]!.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) + #expect(!newEnv.description.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) + #expect(!newEnv.description.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) } #endif } diff --git a/Tests/SwiftlyTests/RunTests.swift b/Tests/SwiftlyTests/RunTests.swift index 09546e18..36fc1ebe 100644 --- a/Tests/SwiftlyTests/RunTests.swift +++ b/Tests/SwiftlyTests/RunTests.swift @@ -27,7 +27,7 @@ import Testing // WHEN: invoking the run command with a selector argument for a toolchain that isn't installed do { try await SwiftlyTests.runCommand(Run.self, ["run", "swift", "+1.2.3", "--version"]) - #expect(false) + Issue.record("This was expected to fail with an error because the toolchain isn't installed") } catch let e as SwiftlyError { #expect(e.message.contains("didn't match any of the installed toolchains")) } @@ -38,7 +38,10 @@ import Testing @Test(.mockedSwiftlyVersion(), .mockHomeToolchains()) func runEnvironment() async throws { // The toolchains directory should be the fist entry on the path let output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", try await Swiftly.currentPlatform.getShell(), "-c", "echo $PATH"]) - #expect(output.count == 1) + guard output.count == 1 else { + Issue.record("Expecting one line of output: \(output)") + return + } #expect(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).string)) } diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 7411f840..e9c10f65 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -2,6 +2,7 @@ import _StringProcessing import ArgumentParser import Foundation import OpenAPIRuntime +import Subprocess @testable import Swiftly @testable import SwiftlyCore import SwiftlyWebsiteAPI @@ -25,24 +26,9 @@ extension Tag { @Tag static var large: Self } -extension Executable { +extension Subprocess.Executable { public func exists() async throws -> Bool { - switch self.storage { - case let .path(p): - return (try await FileSystem.exists(atPath: p)) - case let .executable(e): - let path = ProcessInfo.processInfo.environment["PATH"] - - guard let path else { return false } - - for p in path.split(separator: ":") { - if try await FileSystem.exists(atPath: FilePath(String(p)) / e) { - return true - } - } - - return false - } + (try? self.resolveExecutablePath(in: .inherit)) != nil } } @@ -998,7 +984,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { .root(swiftlyDir), package_output_path: pkg ) - .run(Swiftly.currentPlatform) + .run() let data = try Data(contentsOf: pkg) try await fs.remove(atPath: tmp) @@ -1045,7 +1031,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { .root(toolchainDir), package_output_path: pkg ) - .run(Swiftly.currentPlatform) + .run() let pkgData = try Data(contentsOf: pkg) try await fs.remove(atPath: tmp) diff --git a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift index 0cdbca1c..d4c7bbe6 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -2,6 +2,7 @@ import ArgumentParser import AsyncHTTPClient import Foundation import NIOFileSystem +import Subprocess import SwiftlyCore import SystemPackage @@ -22,12 +23,10 @@ typealias sys = SystemCommand extension Runnable { // Runs the command while echoing the full command-line to stdout for logging and reproduction - func runEcho(_ platform: Platform, quiet: Bool = false) async throws { + func runEcho(environment: Environment = .inherit, quiet: Bool = false) async throws { let config = self.config() - // if !quiet { print("\(args.joined(separator: " "))") } - if !quiet { print("\(config)") } - - try await self.run(platform) + if !quiet { print("\(config.executable) \(config.arguments)") } + try await self.run(environment: environment, quiet: quiet) } } @@ -111,12 +110,12 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { return } - guard let gitTags = try await sys.git().log(.max_count("1"), .pretty("format:%d")).output(currentPlatform), gitTags.contains("tag: \(self.version)") else { + guard let gitTags = try await sys.git().log(.max_count("1"), .pretty("format:%d")).output(limit: 1024), gitTags.contains("tag: \(self.version)") else { throw Error(message: "Git repo is not yet tagged for release \(self.version). Please tag this commit with that version and push it to GitHub.") } do { - try await sys.git().diffindex(.quiet, tree_ish: "HEAD").run(currentPlatform) + try await sys.git().diffindex(.quiet, tree_ish: "HEAD").runEcho() } catch { throw Error(message: "Git repo has local changes. First commit these changes, tag the commit with release \(self.version) and push the tag to GitHub.") } @@ -135,7 +134,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { try await self.checkGitRepoStatus() // Start with a fresh SwiftPM package - try await sys.swift().package().reset().run(currentPlatform) + try await sys.swift().package().reset().runEcho() // Build a specific version of libarchive with a check on the tarball's SHA256 let libArchiveVersion = "3.8.1" @@ -166,19 +165,25 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { } } - let libArchiveTarShaActual = try await sys.sha256sum(files: buildCheckoutsDir / "libarchive-\(libArchiveVersion).tar.gz").output(currentPlatform) + let libArchiveTarShaActual = try await sys.sha256sum(files: buildCheckoutsDir / "libarchive-\(libArchiveVersion).tar.gz").output(limit: 1024) guard let libArchiveTarShaActual, libArchiveTarShaActual.starts(with: libArchiveTarSha) else { let shaActual = libArchiveTarShaActual ?? "none" throw Error(message: "The libarchive tar.gz file sha256sum is \(shaActual), but expected \(libArchiveTarSha)") } - try await sys.tar(.directory(buildCheckoutsDir)).extract(.compressed, .archive(buildCheckoutsDir / "libarchive-\(libArchiveVersion).tar.gz")).run(currentPlatform) + try await sys.tar(.directory(buildCheckoutsDir)).extract(.compressed, .archive(buildCheckoutsDir / "libarchive-\(libArchiveVersion).tar.gz")).runEcho() let cwd = fs.cwd FileManager.default.changeCurrentDirectoryPath(libArchivePath.string) let swiftVerRegex: Regex<(Substring, Substring)> = try! Regex("Swift version (\\d+\\.\\d+\\.?\\d*) ") - let swiftVerOutput = (try await currentPlatform.runProgramOutput("swift", "--version")) ?? "" + let swiftVersionCmd = Configuration( + .name("swift"), + arguments: ["--version"] + ) + print("\(swiftVersionCmd.executable) \(swiftVersionCmd.arguments)") + + let swiftVerOutput = (try await Subprocess.run(swiftVersionCmd, output: .string(limit: 1024))).standardOutput ?? "" guard let swiftVerMatch = try swiftVerRegex.firstMatch(in: swiftVerOutput) else { throw Error(message: "Unable to detect swift version") } @@ -200,46 +205,61 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { throw Error(message: "Swift release \(swiftVersion) has no Static SDK offering") } - try await sys.swift().sdk().install(.checksum(sdkPlatform.checksum ?? "deadbeef"), bundle_path_or_url: "https://download.swift.org/swift-\(swiftVersion)-release/static-sdk/swift-\(swiftVersion)-RELEASE/swift-\(swiftVersion)-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz").run(currentPlatform) - - var customEnv = ProcessInfo.processInfo.environment - customEnv["CC"] = "\(cwd)/Tools/build-swiftly-release/musl-clang" - customEnv["MUSL_PREFIX"] = "\(fs.home / ".swiftpm/swift-sdks/\(sdkName).artifactbundle/\(sdkName)/swift-linux-musl/musl-1.2.5.sdk/\(arch)/usr")" - - try currentPlatform.runProgram( - "./configure", - "--prefix=\(pkgConfigPath)", - "--enable-shared=no", - "--with-pic", - "--without-nettle", - "--without-openssl", - "--without-lzo2", - "--without-expat", - "--without-xml2", - "--without-bz2lib", - "--without-libb2", - "--without-iconv", - "--without-zstd", - "--without-lzma", - "--without-lz4", - "--disable-acl", - "--disable-bsdtar", - "--disable-bsdcat", - env: customEnv + try await sys.swift().sdk().install(.checksum(sdkPlatform.checksum ?? "deadbeef"), bundle_path_or_url: "https://download.swift.org/swift-\(swiftVersion)-release/static-sdk/swift-\(swiftVersion)-RELEASE/swift-\(swiftVersion)-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz").runEcho() + + var customEnv: Environment = .inherit + customEnv = customEnv.updating([ + "CC": "\(cwd)/Tools/build-swiftly-release/musl-clang", + "MUSL_PREFIX": "\(fs.home / ".swiftpm/swift-sdks/\(sdkName).artifactbundle/\(sdkName)/swift-linux-musl/musl-1.2.5.sdk/\(arch)/usr")", + ]) + + let configCmd = Configuration( + .path(FilePath("./configure")), + arguments: [ + "--prefix=\(pkgConfigPath)", + "--enable-shared=no", + "--with-pic", + "--without-nettle", + "--without-openssl", + "--without-lzo2", + "--without-expat", + "--without-xml2", + "--without-bz2lib", + "--without-libb2", + "--without-iconv", + "--without-zstd", + "--without-lzma", + "--without-lz4", + "--disable-acl", + "--disable-bsdtar", + "--disable-bsdcat", + ], + environment: customEnv, ) + print("\(configCmd.executable) \(configCmd.arguments)") + + let result = try await Subprocess.run( + configCmd, + output: .standardOutput, + error: .standardError, + ) + + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: configCmd) + } - try await sys.make().run(currentPlatform, env: customEnv) + try await sys.make().runEcho(environment: customEnv) - try await sys.make().install().run(currentPlatform) + try await sys.make().install().runEcho() FileManager.default.changeCurrentDirectoryPath(cwd.string) - try await sys.swift().build(.swift_sdk("\(arch)-swift-linux-musl"), .product("swiftly"), .pkg_config_path(pkgConfigPath / "lib/pkgconfig"), .static_swift_stdlib, .configuration("release")).run(currentPlatform) + try await sys.swift().build(.swift_sdk("\(arch)-swift-linux-musl"), .product("swiftly"), .pkg_config_path(pkgConfigPath / "lib/pkgconfig"), .static_swift_stdlib, .configuration("release")).runEcho() let releaseDir = cwd / ".build/release" // Strip the symbols from the binary to decrease its size - try await sys.strip(name: releaseDir / "swiftly").run(currentPlatform) + try await sys.strip(name: releaseDir / "swiftly").runEcho() try await self.collectLicenses(releaseDir) @@ -249,7 +269,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { let releaseArchive = releaseDir / "swiftly-\(version)-x86_64.tar.gz" #endif - try await sys.tar(.directory(releaseDir)).create(.compressed, .archive(releaseArchive), files: ["swiftly", "LICENSE.txt"]).run(currentPlatform) + try await sys.tar(.directory(releaseDir)).create(.compressed, .archive(releaseArchive), files: ["swiftly", "LICENSE.txt"]).runEcho() print(releaseArchive) @@ -262,23 +282,23 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { let testArchive = debugDir / "test-swiftly-linux-x86_64.tar.gz" #endif - try await sys.swift().build(.swift_sdk("\(arch)-swift-linux-musl"), .product("test-swiftly"), .pkg_config_path(pkgConfigPath / "lib/pkgconfig"), .static_swift_stdlib, .configuration("debug")).run(currentPlatform) - try await sys.tar(.directory(debugDir)).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).run(currentPlatform) + try await sys.swift().build(.swift_sdk("\(arch)-swift-linux-musl"), .product("test-swiftly"), .pkg_config_path(pkgConfigPath / "lib/pkgconfig"), .static_swift_stdlib, .configuration("debug")).runEcho() + try await sys.tar(.directory(debugDir)).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).runEcho() print(testArchive) } - try await sys.swift().sdk().remove(sdk_id_or_bundle_name: sdkName).runEcho(currentPlatform) + try await sys.swift().sdk().remove(sdk_id_or_bundle_name: sdkName).runEcho() } func buildMacOSRelease(cert: String?, identifier: String) async throws { try await self.checkGitRepoStatus() - try await sys.swift().package().clean().run(currentPlatform) + try await sys.swift().package().clean().runEcho() for arch in ["x86_64", "arm64"] { - try await sys.swift().build(.product("swiftly"), .configuration("release"), .arch("\(arch)")).run(currentPlatform) - try await sys.strip(name: FilePath(".build") / "\(arch)-apple-macosx/release/swiftly").run(currentPlatform) + try await sys.swift().build(.product("swiftly"), .configuration("release"), .arch("\(arch)")).runEcho() + try await sys.strip(name: FilePath(".build") / "\(arch)-apple-macosx/release/swiftly").runEcho() } let swiftlyBinDir = fs.cwd / ".build/release/.swiftly/bin" @@ -288,7 +308,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { input_file: ".build/x86_64-apple-macosx/release/swiftly", ".build/arm64-apple-macosx/release/swiftly" ) .create(.output(swiftlyBinDir / "swiftly")) - .runEcho(currentPlatform) + .runEcho() let swiftlyLicenseDir = fs.cwd / ".build/release/.swiftly/license" try? await fs.mkdir(.parents, atPath: swiftlyLicenseDir) @@ -307,7 +327,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { .sign(cert), .root(swiftlyBinDir.parent), package_output_path: releaseDir / "swiftly-\(self.version).pkg" - ).runEcho(currentPlatform) + ).runEcho() } else { try await sys.pkgbuild( .install_location(".swiftly"), @@ -315,7 +335,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { .identifier(identifier), .root(swiftlyBinDir.parent), package_output_path: releaseDir / "swiftly-\(self.version).pkg" - ).runEcho(currentPlatform) + ).runEcho() } // Re-configure the pkg to prefer installs into the current user's home directory with the help of productbuild. @@ -324,16 +344,16 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { let pkgFileReconfigured = releaseDir / "swiftly-\(self.version)-reconfigured.pkg" let distFile = releaseDir / "distribution.plist" - try await sys.productbuild(.synthesize, .pkg_path(pkgFile), output_path: distFile).runEcho(currentPlatform) + try await sys.productbuild(.synthesize, .pkg_path(pkgFile), output_path: distFile).runEcho() var distFileContents = try String(contentsOf: distFile, encoding: .utf8) distFileContents = distFileContents.replacingOccurrences(of: "", with: "swiftly") try distFileContents.write(to: distFile, atomically: true, encoding: .utf8) if let cert = cert { - try await sys.productbuild(.search_path(pkgFile.parent), .cert(cert), .dist_path(distFile), output_path: pkgFileReconfigured).runEcho(currentPlatform) + try await sys.productbuild(.search_path(pkgFile.parent), .cert(cert), .dist_path(distFile), output_path: pkgFileReconfigured).runEcho() } else { - try await sys.productbuild(.search_path(pkgFile.parent), .dist_path(distFile), output_path: pkgFileReconfigured).runEcho(currentPlatform) + try await sys.productbuild(.search_path(pkgFile.parent), .dist_path(distFile), output_path: pkgFileReconfigured).runEcho() } try await fs.remove(atPath: pkgFile) try await fs.copy(atPath: pkgFileReconfigured, toPath: pkgFile) @@ -342,8 +362,8 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { if self.test { for arch in ["x86_64", "arm64"] { - try await sys.swift().build(.product("test-swiftly"), .configuration("debug"), .arch("\(arch)")).runEcho(currentPlatform) - try await sys.strip(name: ".build" / "\(arch)-apple-macosx/release/swiftly").runEcho(currentPlatform) + try await sys.swift().build(.product("test-swiftly"), .configuration("debug"), .arch("\(arch)")).runEcho() + try await sys.strip(name: ".build" / "\(arch)-apple-macosx/release/swiftly").runEcho() } let testArchive = releaseDir / "test-swiftly-macos.tar.gz" @@ -352,9 +372,9 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { input_file: ".build/x86_64-apple-macosx/debug/test-swiftly", ".build/arm64-apple-macosx/debug/test-swiftly" ) .create(.output(swiftlyBinDir / "swiftly")) - .runEcho(currentPlatform) + .runEcho() - try await sys.tar(.directory(".build/x86_64-apple-macosx/debug")).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).run(currentPlatform) + try await sys.tar(.directory(".build/x86_64-apple-macosx/debug")).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).runEcho() print(testArchive) } diff --git a/Tools/generate-command-models/GenerateCommandModels.swift b/Tools/generate-command-models/GenerateCommandModels.swift index 37c916bd..208d54c4 100644 --- a/Tools/generate-command-models/GenerateCommandModels.swift +++ b/Tools/generate-command-models/GenerateCommandModels.swift @@ -36,6 +36,7 @@ struct GenerateCommandModels: AsyncParsableCommand { var allCmds = """ import SystemPackage + import Subprocess """ @@ -276,18 +277,36 @@ struct GenerateCommandModels: AsyncParsableCommand { """ } + let argumentsFunc: String + if path.count == 0 { + argumentsFunc = """ + public func commandArgs() -> [String] { + var genArgs: [String] = [] + + \((options.asArgs + vars.asArgs).joined(separator: "\n" + indent(1))) + + return genArgs + } + """.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + indent(1)) + } else { + argumentsFunc = """ + public func commandArgs() -> [String] { + var genArgs: [String] = self.parent.commandArgs() + ["\(execName)"] + + \((options.asArgs + vars.asArgs).joined(separator: "\n" + indent(1))) + + return genArgs + } + """.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + indent(1)) + } + let configFunc: String if path.count == 0 { - let genArgs = options.asArgs + vars.asArgs configFunc = """ public func config() -> Configuration { - \(genArgs.isEmpty ? "let" : "var") genArgs: [String] = [] - - \(genArgs.joined(separator: "\n" + indent(1))) - return Configuration( executable: self.executable, - arguments: Arguments(genArgs), + arguments: Arguments(self.commandArgs()), environment: .inherit ) } @@ -297,13 +316,7 @@ struct GenerateCommandModels: AsyncParsableCommand { public func config() -> Configuration { var c = self.parent.config() - var genArgs = c.arguments.storage.map(\\.description) - - genArgs.append("\(execName)") - - \((options.asArgs + vars.asArgs).joined(separator: "\n" + indent(1))) - - c.arguments = .init(genArgs) + c.arguments = .init(self.commandArgs()) return c } @@ -338,6 +351,8 @@ struct GenerateCommandModels: AsyncParsableCommand { \(([path.count == 0 ? "self.executable = executable" : "self.parent = parent"] + options.asInitialization + vars.asInitializations).joined(separator: "\n" + indent(2))) } + \(argumentsFunc) + \(configFunc) \(subcommands)