From dd1d29cf49b200b0c60b183a821a1feffe55e47d Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Wed, 13 Aug 2025 16:23:50 -0400 Subject: [PATCH 01/16] Use Subprocess for process management --- Package.resolved | 16 +- Package.swift | 2 + Sources/MacOSPlatform/MacOS.swift | 2 +- Sources/SwiftlyCore/ModeledCommandLine.swift | 2 +- Sources/SwiftlyCore/Platform.swift | 160 ++++++++++-------- Sources/TestSwiftly/TestSwiftly.swift | 6 +- .../BuildSwiftlyRelease.swift | 2 +- 7 files changed, 110 insertions(+), 80 deletions(-) diff --git a/Package.resolved b/Package.resolved index a948421e..5dbd3e1c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9731c883c87b6c53588707a1dbbc85fb7c43f965751c47869247d8dd8fe67f1e", + "originHash" : "5516525be0e9028235a6e894a82da546c8194a90651e5cbcf922f1bf20cb2c5f", "pins" : [ { "identity" : "async-http-client", @@ -181,13 +181,21 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess", + "state" : { + "revision" : "afc1f734feb29c3a1ebbd97cc1fe943f8e5d80e5" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", - "version" : "1.4.2" + "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", + "version" : "1.5.0" } }, { @@ -219,4 +227,4 @@ } ], "version" : 3 -} \ No newline at end of file +} diff --git a/Package.swift b/Package.swift index c550ffd5..135c3270 100644 --- a/Package.swift +++ b/Package.swift @@ -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", revision: "afc1f734feb29c3a1ebbd97cc1fe943f8e5d80e5"), // 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/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index 5f8fb0c4..018bd16c 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -142,7 +142,7 @@ public struct MacOS: Platform { try await sys.tar(.directory(installDir)).extract(.verbose, .archive(payload)).run(self, quiet: false) } - try self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") + try await self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") } public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) diff --git a/Sources/SwiftlyCore/ModeledCommandLine.swift b/Sources/SwiftlyCore/ModeledCommandLine.swift index 10be5173..c291f13b 100644 --- a/Sources/SwiftlyCore/ModeledCommandLine.swift +++ b/Sources/SwiftlyCore/ModeledCommandLine.swift @@ -181,7 +181,7 @@ extension Runnable { newEnv = newValue } - try p.runProgram([executable] + args, quiet: quiet, env: newEnv) + try await p.runProgram([executable] + args, quiet: quiet, env: newEnv) } } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index e4782fc3..227b0799 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,5 +1,11 @@ import Foundation import SystemPackage +import Subprocess +#if os(macOS) +import System +#else +import SystemPackage +#endif public struct PlatformDefinition: Codable, Equatable, Sendable { /// The name of the platform as it is used in the Swift download URLs. @@ -57,7 +63,7 @@ public struct RunProgramError: Swift.Error { public protocol Platform: Sendable { /// The platform-specific default location on disk for swiftly's home /// directory. - var defaultSwiftlyHomeDir: FilePath { get } + var defaultSwiftlyHomeDir: SystemPackage.FilePath { get } /// The directory which stores the swiftly executable itself as well as symlinks /// to executables in the "bin" directory of the active toolchain. @@ -65,10 +71,10 @@ public protocol Platform: Sendable { /// If a mocked home directory is set, this will be the "bin" subdirectory of the home directory. /// If not, this will be the SWIFTLY_BIN_DIR environment variable if set. If that's also unset, /// this will default to the platform's default location. - func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath + func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath /// The "toolchains" subdirectory that contains the Swift toolchains managed by swiftly. - func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath + func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath /// The file extension of the downloaded toolchain for this platform. /// e.g. for Linux systems this is "tar.gz" and on macOS it's "pkg". @@ -76,12 +82,12 @@ public protocol Platform: Sendable { /// Installs a toolchain from a file on disk pointed to by the given path. /// After this completes, a user can “use” the toolchain. - func install(_ ctx: SwiftlyCoreContext, from: FilePath, version: ToolchainVersion, verbose: Bool) + func install(_ ctx: SwiftlyCoreContext, from: SystemPackage.FilePath, version: ToolchainVersion, verbose: Bool) async throws /// Extract swiftly from the provided downloaded archive and install /// ourselves from that. - func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws + func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: SystemPackage.FilePath) async throws /// Uninstalls a toolchain associated with the given version. /// If this version is in use, the next latest version will be used afterwards. @@ -111,14 +117,14 @@ public protocol Platform: Sendable { /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifyToolchainSignature( - _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: FilePath, verbose: Bool + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: SystemPackage.FilePath, verbose: Bool ) async throws /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifySwiftlySignature( - _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: SystemPackage.FilePath, verbose: Bool ) async throws /// Detect the platform definition for this platform. @@ -129,10 +135,10 @@ public protocol Platform: Sendable { func getShell() async throws -> String /// Find the location where the toolchain should be installed. - func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath + func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath /// Find the location of the toolchain binaries. - func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath + func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath } extension Platform { @@ -149,14 +155,14 @@ extension Platform { /// -- config.json /// ``` /// - public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { + public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { ctx.mockedHomeDir ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { FilePath($0) } ?? self.defaultSwiftlyHomeDir } /// The path of the configuration file in swiftly's home directory. - public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> FilePath { + public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { self.swiftlyHomeDir(ctx) / "config.json" } @@ -216,7 +222,7 @@ extension Platform { } #endif - try self.runProgram([commandToRun] + arguments, env: newEnv) + try await self.runProgram([commandToRun] + arguments, env: newEnv) } /// Proxy the invocation of the provided command to the chosen toolchain and capture the output. @@ -243,9 +249,9 @@ extension Platform { /// the exit code and program information. /// public func runProgram(_ args: String..., quiet: Bool = false, env: [String: String]? = nil) - throws + async throws { - try self.runProgram([String](args), quiet: quiet, env: env) + try await self.runProgram([String](args), quiet: quiet, env: env) } /// Run a program. @@ -254,39 +260,65 @@ extension Platform { /// the exit code and program information. /// public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) - throws + async throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = args + if !quiet { + let result = try await run( + .path("/usr/bin/env"), + arguments: .init(args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + output: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), + ) - if let env { - process.environment = env - } + // TODO figure out how to set the process group + // 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) + } - if quiet { - process.standardOutput = nil - process.standardError = nil - } + defer { + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + } + } - 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) - } + process.waitUntilExit()*/ - defer { + if case .exited(let code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) + } + } else { + let result = try await run( + .path("/usr/bin/env"), + arguments: .init(args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + output: .discarded, + error: .discarded, + ) + + // TODO figure out how to set the process group + // 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, pgid) + tcsetpgrp(STDOUT_FILENO, process.processIdentifier) } - } - process.waitUntilExit() + 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())) + if case .exited(let code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) + } } + + // TODO handle exits with a signal } /// Run a program and capture its output. @@ -308,22 +340,17 @@ extension Platform { 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) + let result = try await run( + .path("/usr/bin/env"), + arguments: .init([program] + args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + input: .none, + output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), + error: .discarded, + ) + + // TODO 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) } @@ -332,20 +359,13 @@ extension Platform { 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 case .exited(let code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } - if let outData { - return String(data: outData, encoding: .utf8) - } else { - return nil - } + return result.standardOutput } // Install ourselves in the final location @@ -353,7 +373,7 @@ extension Platform { // First, let's find out where we are. let cmd = CommandLine.arguments[0] - var cmdAbsolute: FilePath? + var cmdAbsolute: SystemPackage.FilePath? if cmd.hasPrefix("/") { cmdAbsolute = FilePath(cmd) @@ -385,7 +405,7 @@ extension Platform { // Proceed to installation only if we're in the user home directory, or a non-system location. let userHome = fs.home - let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + let systemRoots: [SystemPackage.FilePath] = ["/usr", "/opt", "/bin"] guard cmdAbsolute.starts(with: userHome) || systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first == nil else { return @@ -421,12 +441,12 @@ extension Platform { } // Find the location where swiftly should be executed. - public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { + public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws -> SystemPackage.FilePath? { let swiftlyHomeBin = self.swiftlyBinDir(ctx) / "swiftly" // First, let's find out where we are. let cmd = CommandLine.arguments[0] - var cmdAbsolute: FilePath? + var cmdAbsolute: SystemPackage.FilePath? if cmd.hasPrefix("/") { cmdAbsolute = FilePath(cmd) } else { @@ -457,7 +477,7 @@ extension Platform { } } - let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + let systemRoots: [SystemPackage.FilePath] = ["/usr", "/opt", "/bin"] // If we are system managed then we know where swiftly should be. let userHome = fs.home @@ -479,7 +499,7 @@ extension Platform { return try await fs.exists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil } - public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath + public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath { (try await self.findToolchainLocation(ctx, toolchain)) / "usr/bin" } diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index 26a64cf3..6f3cafeb 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -119,7 +119,7 @@ struct TestSwiftly: AsyncParsableCommand { 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 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) } else { print("Installing swiftly to the default location.") @@ -132,7 +132,7 @@ struct TestSwiftly: AsyncParsableCommand { env["XDG_CONFIG_HOME"] = (fs.home / ".config").string } - try currentPlatform.runProgram(extractedSwiftly.string, "init", "--assume-yes", "--skip-install", quiet: false, env: env) + try await 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) } @@ -140,7 +140,7 @@ struct TestSwiftly: AsyncParsableCommand { if NSUserName() == "root" { if try await fs.exists(atPath: "./post-install.sh") { - try currentPlatform.runProgram(shell.string, "./post-install.sh", quiet: false) + try await currentPlatform.runProgram(shell.string, "./post-install.sh", quiet: false) } swiftReady = true } else if try await fs.exists(atPath: "./post-install.sh") { diff --git a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift index 2a7f8c94..4561d5cb 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -204,7 +204,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { 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( + try await currentPlatform.runProgram( "./configure", "--prefix=\(pkgConfigPath)", "--enable-shared=no", From ae3f8fc731f40fb01f11bc930868fb511a6121a0 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Wed, 13 Aug 2025 16:31:48 -0400 Subject: [PATCH 02/16] Add awaits to Linux platform and reformat --- Sources/LinuxPlatform/Linux.swift | 6 +-- Sources/SwiftlyCore/Platform.swift | 78 +++++++++++++++--------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 386e69db..1d031509 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -255,7 +255,7 @@ public struct Linux: Platform { } if requireSignatureValidation { - guard (try? self.runProgram("gpg", "--version", quiet: true)) != nil else { + guard (try? await self.runProgram("gpg", "--version", quiet: true)) != nil else { var msg = "gpg is not installed. " if let manager { msg += """ @@ -321,7 +321,7 @@ public struct Linux: Platform { } return false case "yum": - try self.runProgram("yum", "list", "installed", package, quiet: true) + try await self.runProgram("yum", "list", "installed", package, quiet: true) return true default: return true @@ -382,7 +382,7 @@ public struct Linux: Platform { tmpDir / String(name) } - try self.runProgram((tmpDir / "swiftly").string, "init") + try await self.runProgram((tmpDir / "swiftly").string, "init") } } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 227b0799..f9cbe1a8 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,6 +1,6 @@ import Foundation -import SystemPackage import Subprocess +import SystemPackage #if os(macOS) import System #else @@ -271,22 +271,22 @@ extension Platform { error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), ) - // TODO figure out how to set the process group + // TODO: figure out how to set the process group // 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) - } + /* let pgid = tcgetpgrp(STDOUT_FILENO) + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, process.processIdentifier) + } - defer { - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - } - } + defer { + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + } + } - process.waitUntilExit()*/ + process.waitUntilExit() */ - if case .exited(let code) = result.terminationStatus, code != 0 { + if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } } else { @@ -298,27 +298,27 @@ extension Platform { error: .discarded, ) - // TODO figure out how to set the process group + // TODO: figure out how to set the process group // 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) - } + /* let pgid = tcgetpgrp(STDOUT_FILENO) + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, process.processIdentifier) + } - defer { - if pgid != -1 { - tcsetpgrp(STDOUT_FILENO, pgid) - } - } + defer { + if pgid != -1 { + tcsetpgrp(STDOUT_FILENO, pgid) + } + } - process.waitUntilExit()*/ + process.waitUntilExit() */ - if case .exited(let code) = result.terminationStatus, code != 0 { + if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } } - // TODO handle exits with a signal + // TODO: handle exits with a signal } /// Run a program and capture its output. @@ -349,19 +349,19 @@ extension Platform { error: .discarded, ) - // TODO 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) - } - } - */ - - if case .exited(let code) = result.terminationStatus, code != 0 { + // TODO: 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) + } + } + */ + + if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } From a3f187f8fae12149ccd4e5f7807c2e92ca29bc77 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 14 Aug 2025 11:09:32 -0400 Subject: [PATCH 03/16] Remove tcsetgrp code because child processes run in the parent process group --- Sources/SwiftlyCore/Platform.swift | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index f9cbe1a8..f4b9766b 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -271,21 +271,6 @@ extension Platform { error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), ) - // TODO: figure out how to set the process group - // 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() */ - if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } @@ -298,21 +283,6 @@ extension Platform { error: .discarded, ) - // TODO: figure out how to set the process group - // 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() */ - if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } From 95875602558fee89494afa04b9386b739b6f3f55 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 14 Aug 2025 11:30:47 -0400 Subject: [PATCH 04/16] Use the common standard input in case someone wants to run interactive tools Forward standard error for runProgramOutput in case there are useful error messages --- Sources/SwiftlyCore/Platform.swift | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index f4b9766b..6e0c575f 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -267,7 +267,8 @@ extension Platform { .path("/usr/bin/env"), arguments: .init(args), environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, - output: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), + input: .fileDescriptor(.standardInput, closeAfterSpawningProcess: false), + output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), ) @@ -314,23 +315,10 @@ extension Platform { .path("/usr/bin/env"), arguments: .init([program] + args), environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, - input: .none, output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), - error: .discarded, + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) ) - // TODO: 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) - } - } - */ - if case let .exited(code) = result.terminationStatus, code != 0 { throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } From 1051196b12064cf59f67a65a87d5bdd1bec38f7e Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 14 Aug 2025 11:41:21 -0400 Subject: [PATCH 05/16] Split out process handling from the core Platform functions --- Sources/SwiftlyCore/Platform+Process.swift | 171 ++++++++++++++++++ Sources/SwiftlyCore/Platform.swift | 197 ++------------------- 2 files changed, 188 insertions(+), 180 deletions(-) create mode 100644 Sources/SwiftlyCore/Platform+Process.swift diff --git a/Sources/SwiftlyCore/Platform+Process.swift b/Sources/SwiftlyCore/Platform+Process.swift new file mode 100644 index 00000000..6eaca7d4 --- /dev/null +++ b/Sources/SwiftlyCore/Platform+Process.swift @@ -0,0 +1,171 @@ +import Foundation +import Subprocess +#if os(macOS) +import System +#endif + +import SystemPackage + +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 await 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) + async throws + { + try await 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) + async throws + { + if !quiet { + let result = try await run( + .path("/usr/bin/env"), + arguments: .init(args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + input: .fileDescriptor(.standardInput, closeAfterSpawningProcess: false), + output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), + ) + + if case let .exited(code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) + } + } else { + let result = try await run( + .path("/usr/bin/env"), + arguments: .init(args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + output: .discarded, + error: .discarded, + ) + + if case let .exited(code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) + } + } + + // TODO: handle exits with a signal + } + + /// 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 result = try await run( + .path("/usr/bin/env"), + arguments: .init([program] + args), + environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) + ) + + if case let .exited(code) = result.terminationStatus, code != 0 { + throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) + } + + return result.standardOutput + } + +#endif +} diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 6e0c575f..c32beea5 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,11 +1,6 @@ import Foundation import Subprocess import SystemPackage -#if os(macOS) -import System -#else -import SystemPackage -#endif public struct PlatformDefinition: Codable, Equatable, Sendable { /// The name of the platform as it is used in the Swift download URLs. @@ -63,7 +58,7 @@ public struct RunProgramError: Swift.Error { public protocol Platform: Sendable { /// The platform-specific default location on disk for swiftly's home /// directory. - var defaultSwiftlyHomeDir: SystemPackage.FilePath { get } + var defaultSwiftlyHomeDir: FilePath { get } /// The directory which stores the swiftly executable itself as well as symlinks /// to executables in the "bin" directory of the active toolchain. @@ -71,10 +66,10 @@ public protocol Platform: Sendable { /// If a mocked home directory is set, this will be the "bin" subdirectory of the home directory. /// If not, this will be the SWIFTLY_BIN_DIR environment variable if set. If that's also unset, /// this will default to the platform's default location. - func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath + func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath /// The "toolchains" subdirectory that contains the Swift toolchains managed by swiftly. - func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath + func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath /// The file extension of the downloaded toolchain for this platform. /// e.g. for Linux systems this is "tar.gz" and on macOS it's "pkg". @@ -82,12 +77,12 @@ public protocol Platform: Sendable { /// Installs a toolchain from a file on disk pointed to by the given path. /// After this completes, a user can “use” the toolchain. - func install(_ ctx: SwiftlyCoreContext, from: SystemPackage.FilePath, version: ToolchainVersion, verbose: Bool) + func install(_ ctx: SwiftlyCoreContext, from: FilePath, version: ToolchainVersion, verbose: Bool) async throws /// Extract swiftly from the provided downloaded archive and install /// ourselves from that. - func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: SystemPackage.FilePath) async throws + func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws /// Uninstalls a toolchain associated with the given version. /// If this version is in use, the next latest version will be used afterwards. @@ -117,14 +112,14 @@ public protocol Platform: Sendable { /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifyToolchainSignature( - _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: SystemPackage.FilePath, verbose: Bool + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: FilePath, verbose: Bool ) async throws /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifySwiftlySignature( - _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: SystemPackage.FilePath, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool ) async throws /// Detect the platform definition for this platform. @@ -135,10 +130,10 @@ public protocol Platform: Sendable { func getShell() async throws -> String /// Find the location where the toolchain should be installed. - func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath + func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath /// Find the location of the toolchain binaries. - func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath + func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath } extension Platform { @@ -155,183 +150,25 @@ extension Platform { /// -- config.json /// ``` /// - public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { + public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { ctx.mockedHomeDir ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { FilePath($0) } ?? self.defaultSwiftlyHomeDir } /// The path of the configuration file in swiftly's home directory. - public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { + public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> FilePath { self.swiftlyHomeDir(ctx) / "config.json" } #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 await 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) - async throws - { - try await 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) - async throws - { - if !quiet { - let result = try await run( - .path("/usr/bin/env"), - arguments: .init(args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, - input: .fileDescriptor(.standardInput, closeAfterSpawningProcess: false), - output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), - error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) - } - } else { - let result = try await run( - .path("/usr/bin/env"), - arguments: .init(args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, - output: .discarded, - error: .discarded, - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) - } - } - - // TODO: handle exits with a signal - } - - /// 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 result = try await run( - .path("/usr/bin/env"), - arguments: .init([program] + args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, - output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), - error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) - } - - return result.standardOutput - } // Install ourselves in the final location public func installSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws { // First, let's find out where we are. let cmd = CommandLine.arguments[0] - var cmdAbsolute: SystemPackage.FilePath? + var cmdAbsolute: FilePath? if cmd.hasPrefix("/") { cmdAbsolute = FilePath(cmd) @@ -363,7 +200,7 @@ extension Platform { // Proceed to installation only if we're in the user home directory, or a non-system location. let userHome = fs.home - let systemRoots: [SystemPackage.FilePath] = ["/usr", "/opt", "/bin"] + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] guard cmdAbsolute.starts(with: userHome) || systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first == nil else { return @@ -399,12 +236,12 @@ extension Platform { } // Find the location where swiftly should be executed. - public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws -> SystemPackage.FilePath? { + public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { let swiftlyHomeBin = self.swiftlyBinDir(ctx) / "swiftly" // First, let's find out where we are. let cmd = CommandLine.arguments[0] - var cmdAbsolute: SystemPackage.FilePath? + var cmdAbsolute: FilePath? if cmd.hasPrefix("/") { cmdAbsolute = FilePath(cmd) } else { @@ -435,7 +272,7 @@ extension Platform { } } - let systemRoots: [SystemPackage.FilePath] = ["/usr", "/opt", "/bin"] + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] // If we are system managed then we know where swiftly should be. let userHome = fs.home @@ -457,7 +294,7 @@ extension Platform { return try await fs.exists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil } - public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath + public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath { (try await self.findToolchainLocation(ctx, toolchain)) / "usr/bin" } From 97a23f05d3ade0d14515a1eec8b19c5a548ac065 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 14 Aug 2025 11:42:06 -0400 Subject: [PATCH 06/16] Cleanup imports --- Sources/SwiftlyCore/Platform.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index c32beea5..b1a6289a 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,5 +1,4 @@ import Foundation -import Subprocess import SystemPackage public struct PlatformDefinition: Codable, Equatable, Sendable { From 7e553fac636e0ba2662dbc0bdbc45ae7cf7967b2 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 28 Oct 2025 15:32:28 -0400 Subject: [PATCH 07/16] Fix compile errors with the new Subprocess version --- Sources/SwiftlyCore/Platform+Process.swift | 30 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftlyCore/Platform+Process.swift b/Sources/SwiftlyCore/Platform+Process.swift index 6eaca7d4..4cfe220b 100644 --- a/Sources/SwiftlyCore/Platform+Process.swift +++ b/Sources/SwiftlyCore/Platform+Process.swift @@ -103,11 +103,23 @@ extension Platform { public func runProgram(_ args: [String], quiet: Bool = false, env: [String: String]? = nil) async throws { + let environment: Subprocess.Environment = if let env { + .inherit.updating( + .init( + uniqueKeysWithValues: env.map { + (Subprocess.Environment.Key(stringLiteral: $0.key), Optional($0.value)) + } + ) + ) + } else { + .inherit + } + if !quiet { let result = try await run( .path("/usr/bin/env"), arguments: .init(args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + environment: environment, input: .fileDescriptor(.standardInput, closeAfterSpawningProcess: false), output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), @@ -120,7 +132,7 @@ extension Platform { let result = try await run( .path("/usr/bin/env"), arguments: .init(args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + environment: environment, output: .discarded, error: .discarded, ) @@ -152,10 +164,22 @@ extension Platform { public func runProgramOutput(_ program: String, _ args: [String], env: [String: String]? = nil) async throws -> String? { + let environment: Subprocess.Environment = if let env { + .inherit.updating( + .init( + uniqueKeysWithValues: env.map { + (Subprocess.Environment.Key(stringLiteral: $0.key), Optional($0.value)) + } + ) + ) + } else { + .inherit + } + let result = try await run( .path("/usr/bin/env"), arguments: .init([program] + args), - environment: env != nil ? .inherit.updating(env ?? [:]) : .inherit, + environment: environment, output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) ) From 23a63df3ea619c351a16cf024f934b5763a1fcd1 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 28 Oct 2025 17:25:37 -0400 Subject: [PATCH 08/16] Disable problematic traits --- Package.resolved | 10 +++++----- Package.swift | 4 ++-- Sources/SwiftlyCore/Platform+Process.swift | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Package.resolved b/Package.resolved index 4d7728db..e904c45e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c149b55a2ddda6362b7c203e740478714f1aa1ad8b1976d5a7830e2b9887e01e", + "originHash" : "f95cf109954483e637b3157eb63792ab8362707f4fb93c0e30ff5ad7638fc82d", "pins" : [ { "identity" : "async-http-client", @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-subprocess", "state" : { - "branch" : "0.2.1", - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" + "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd", + "version" : "0.2.1" } }, { @@ -258,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" } }, { diff --git a/Package.swift b/Package.swift index e2eb7050..bc10c09d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 import PackageDescription @@ -31,7 +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", revision: "0.2.1"), + .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"), ], diff --git a/Sources/SwiftlyCore/Platform+Process.swift b/Sources/SwiftlyCore/Platform+Process.swift index 4cfe220b..66f735e0 100644 --- a/Sources/SwiftlyCore/Platform+Process.swift +++ b/Sources/SwiftlyCore/Platform+Process.swift @@ -107,7 +107,7 @@ extension Platform { .inherit.updating( .init( uniqueKeysWithValues: env.map { - (Subprocess.Environment.Key(stringLiteral: $0.key), Optional($0.value)) + (Subprocess.Environment.Key(stringLiteral: $0.key), $0.value) } ) ) @@ -168,7 +168,7 @@ extension Platform { .inherit.updating( .init( uniqueKeysWithValues: env.map { - (Subprocess.Environment.Key(stringLiteral: $0.key), Optional($0.value)) + (Subprocess.Environment.Key(stringLiteral: $0.key), $0.value) } ) ) From 27ea36b39305b175be61d8eb5b6a28319a4d86f3 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 31 Oct 2025 10:31:18 -0400 Subject: [PATCH 09/16] Remove Platform.run methods in favour of more direct Subprocess usages --- Sources/LinuxPlatform/Linux.swift | 48 ++- Sources/MacOSPlatform/MacOS.swift | 42 ++- Sources/Swiftly/Proxy.swift | 20 +- Sources/Swiftly/Run.swift | 53 ++- .../Commands+Runnable+Output.swift | 8 +- Sources/SwiftlyCore/ModeledCommandLine.swift | 240 +++--------- Sources/SwiftlyCore/Platform+Process.swift | 178 ++------- Sources/SwiftlyCore/Platform.swift | 11 +- Sources/TestSwiftly/TestSwiftly.swift | 64 ++-- Tests/SwiftlyTests/CommandLineTests.swift | 342 ++++++++++++------ Tests/SwiftlyTests/HTTPClientTests.swift | 5 +- Tests/SwiftlyTests/PlatformTests.swift | 14 +- Tests/SwiftlyTests/RunTests.swift | 7 +- Tests/SwiftlyTests/SwiftlyTests.swift | 33 +- .../BuildSwiftlyRelease.swift | 118 +++--- .../GenerateCommandModels.swift | 38 +- 16 files changed, 577 insertions(+), 644 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 1d031509..395c019b 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess import SwiftlyCore import SystemPackage @@ -255,7 +256,13 @@ public struct Linux: Platform { } if requireSignatureValidation { - guard (try? await 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 += """ @@ -277,9 +284,9 @@ public struct Linux: Platform { 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) } } } @@ -307,7 +314,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: @@ -321,8 +333,8 @@ public struct Linux: Platform { } return false case "yum": - try await 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 } @@ -382,7 +394,15 @@ public struct Linux: Platform { tmpDir / String(name) } - try await 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) + } } } @@ -416,11 +436,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).") @@ -445,11 +463,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).") @@ -603,7 +619,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 47f5626d..b5bb9740 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -1,5 +1,7 @@ import Foundation +import Subprocess import SwiftlyCore +import System import SystemPackage typealias sys = SwiftlyCore.SystemCommand @@ -17,21 +19,21 @@ public struct SwiftPkgInfo: Codable { public struct MacOS: Platform { public init() {} - public var defaultSwiftlyHomeDir: FilePath { + public var defaultSwiftlyHomeDir: SystemPackage.FilePath { fs.home / ".swiftly" } - public var defaultToolchainsDirectory: FilePath { + public var defaultToolchainsDirectory: SystemPackage.FilePath { fs.home / "Library/Developer/Toolchains" } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { ctx.mockedHomeDir.map { $0 / "bin" } ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } ?? fs.home / ".swiftly/bin" } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { ctx.mockedHomeDir.map { $0 / "Toolchains" } ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { FilePath($0) } // This is where the installer will put the toolchains, and where Xcode can find them @@ -55,7 +57,7 @@ public struct MacOS: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: SystemPackage.FilePath, version: ToolchainVersion, verbose: Bool ) async throws { guard try await fs.exists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") @@ -71,7 +73,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 +86,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 +96,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() // 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,11 +106,11 @@ 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) } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: SystemPackage.FilePath) async throws { guard try await fs.exists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } @@ -120,8 +122,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 +131,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 +141,10 @@ 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 await self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") + _ = try await run(.path(System.FilePath((userHomeDir / ".swiftly/bin/swiftly").string)), arguments: ["init"], input: .standardInput, output: .standardOutput, error: .standardError) } public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) @@ -164,7 +166,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 { @@ -172,14 +174,14 @@ public struct MacOS: Platform { } public func verifyToolchainSignature( - _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: FilePath, verbose _: Bool + _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: SystemPackage.FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. } public func verifySwiftlySignature( - _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: FilePath, verbose _: Bool + _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: SystemPackage.FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. @@ -201,11 +203,11 @@ public struct MacOS: Platform { return "/bin/zsh" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath { 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(SystemPackage.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..51d9954d 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,24 @@ 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) + + _ = try await Subprocess.run( + .name(binName), + arguments: Arguments(Array(CommandLine.arguments[1...])), + environment: env.updating(["SWIFTLY_PROXY_IN_PROGRESS": "1"]), + input: .standardInput, + output: .standardOutput, + error: .standardError + ) } 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 c291f13b..9a7cfcdf 100644 --- a/Sources/SwiftlyCore/ModeledCommandLine.swift +++ b/Sources/SwiftlyCore/ModeledCommandLine.swift @@ -1,4 +1,5 @@ import Foundation +import Subprocess import SystemPackage public enum CommandLineError: Error { @@ -7,209 +8,74 @@ public enum CommandLineError: Error { 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)) - } -} - -internal enum StringOrRawBytes: Sendable, Hashable { - case string(String) - - var stringValue: String? { - switch self { - case let .string(string): - return string - } - } - - var description: String { - switch self { - case let .string(string): - return string - } - } - - var count: Int { - switch self { - case let .string(string): - return string.count - } - } - - 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) - } - } -} - -public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { - public typealias ArrayLiteralElement = String - - internal let storage: [StringOrRawBytes] - - /// Create an Arguments object using the given literal values - public init(arrayLiteral elements: String...) { - self.storage = elements.map { .string($0) } - } - - /// Create an Arguments object using the given array - public init(_ array: [String]) { - self.storage = array.map { .string($0) } - } -} - -// 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 - } - } -} - -extension Arguments: CustomStringConvertible { - public var description: String { - let normalized: [String] = self.storage.map(\.description).map { - $0.contains(" ") ? "\"\($0)\"" : String($0) - } - - return normalized.joined(separator: " ") - } -} - -extension Configuration: CustomStringConvertible { - public var description: String { - "\(self.executable) \(self.arguments)" - } -} - 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 + 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) } - let args = c.arguments.storage.map(\.description) - var newEnv: [String: String] = env + return result + } - switch c.environment.config { - case let .inherit(newValue): - for (key, value) in newValue { - newEnv[key] = value + 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 + + 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) } - case let .custom(newValue): - newEnv = newValue } - - try await p.runProgram([executable] + args, quiet: quiet, env: newEnv) } } -public protocol Output { - func config() -> Configuration -} +public protocol Output: Runnable {} // 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 - } - case let .custom(newValue): - env = newValue - } - return try await p.runProgramOutput(executable, args, env: env) + public func output( + environment: Environment = .inherit, + limit: Int + ) 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 + + let output = try await Subprocess.run( + self.config(), + output: .string(limit: limit), + error: .standardError + ) + + return output.standardOutput } } diff --git a/Sources/SwiftlyCore/Platform+Process.swift b/Sources/SwiftlyCore/Platform+Process.swift index 66f735e0..a92d894e 100644 --- a/Sources/SwiftlyCore/Platform+Process.swift +++ b/Sources/SwiftlyCore/Platform+Process.swift @@ -6,10 +6,18 @@ import System 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) - func proxyEnv(_ ctx: SwiftlyCoreContext, env: [String: String], toolchain: ToolchainVersion) async throws -> [String: String] { - var newEnv = env + 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 { @@ -19,7 +27,8 @@ extension Platform { ) } - var pathComponents = (newEnv["PATH"] ?? "").split(separator: ":").map { String($0) } + 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 }) @@ -29,166 +38,25 @@ extension Platform { 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 - } + 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 newEnv["SDKROOT"] == nil { - newEnv["SDKROOT"] = (try? await self.runProgramOutput("/usr/bin/xcrun", "--show-sdk-path"))?.replacingOccurrences(of: "\n", with: "") - } -#endif - - try await 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) - async throws - { - try await 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) - async throws - { - let environment: Subprocess.Environment = if let env { - .inherit.updating( - .init( - uniqueKeysWithValues: env.map { - (Subprocess.Environment.Key(stringLiteral: $0.key), $0.value) - } - ) - ) - } else { - .inherit - } - - if !quiet { - let result = try await run( - .path("/usr/bin/env"), - arguments: .init(args), - environment: environment, - input: .fileDescriptor(.standardInput, closeAfterSpawningProcess: false), - output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), - error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false), - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) - } - } else { - let result = try await run( - .path("/usr/bin/env"), - arguments: .init(args), - environment: environment, - output: .discarded, - error: .discarded, - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) - } - } - - // TODO: handle exits with a signal - } - - /// 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 environment: Subprocess.Environment = if let env { - .inherit.updating( - .init( - uniqueKeysWithValues: env.map { - (Subprocess.Environment.Key(stringLiteral: $0.key), $0.value) - } - ) + 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: ""), + ] ) - } else { - .inherit - } - - let result = try await run( - .path("/usr/bin/env"), - arguments: .init([program] + args), - environment: environment, - output: .string(limit: 10 * 1024 * 1024, encoding: UTF8.self), - error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) - ) - - if case let .exited(code) = result.terminationStatus, code != 0 { - throw RunProgramError(exitCode: code, program: args.first!, arguments: Array(args.dropFirst())) } +#endif - return result.standardOutput + return environment } #endif diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index b1a6289a..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 { diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index 5fa1f956..91d7037e 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -1,8 +1,13 @@ import ArgumentParser import Foundation +import Subprocess import SwiftlyCore import SystemPackage +#if os(macOS) +import System +#endif + #if os(Linux) import LinuxPlatform #elseif os(macOS) @@ -92,55 +97,58 @@ struct TestSwiftly: AsyncParsableCommand { Foundation.exit(2) } - guard case let swiftlyArchive = FilePath(swiftlyArchive) else { fatalError("") } + guard case let swiftlyArchive = SystemPackage.FilePath(swiftlyArchive) else { fatalError("") } print("Extracting swiftly release") #if os(Linux) - try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchive)).run(currentPlatform, quiet: false) + try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchive)).run(quiet: false) #elseif os(macOS) - try await sys.installer(.verbose, .pkg(swiftlyArchive), .target("CurrentUserHomeDirectory")).run(currentPlatform, quiet: false) + try await sys.installer(.verbose, .pkg(swiftlyArchive), .target("CurrentUserHomeDirectory")).run(quiet: false) #endif #if os(Linux) - let extractedSwiftly = FilePath("./swiftly") + let extractedSwiftly = SystemPackage.FilePath("./swiftly") #elseif os(macOS) - let extractedSwiftly = fs.home / ".swiftly/bin/swiftly" + let extractedSwiftly = System.FilePath((fs.home / ".swiftly/bin/swiftly").string) #endif - var env = ProcessInfo.processInfo.environment - let shell = FilePath(try await currentPlatform.getShell()) - var customLoc: FilePath? + var env: Environment = .inherit + let shell = SystemPackage.FilePath(try await currentPlatform.getShell()) + var customLoc: SystemPackage.FilePath? if self.customLocation { 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 await 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, + ]) + + _ = try await Subprocess.run(.path(extractedSwiftly), arguments: ["init", "--assume-yes", "--no-modify-profile", "--skip-install"], environment: env, input: .standardInput, output: .standardOutput, error: .standardError) + _ = 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, output: .standardOutput, error: .standardError) } 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 await 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) + _ = try await Subprocess.run(.path(extractedSwiftly), arguments: ["init", "--assume-yes", "--skip-install"], environment: env, input: .standardInput, output: .standardOutput, error: .standardError) + _ = try await sh(executable: .path(shell), .login, .command("swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(environment: env, output: .standardOutput, error: .standardError) } var swiftReady = false if NSUserName() == "root" { if try await fs.exists(atPath: "./post-install.sh") { - try await currentPlatform.runProgram(shell.string, "./post-install.sh", quiet: false) + _ = try await Subprocess.run(.path(shell), arguments: ["./post-install.sh"], input: .standardInput, output: .standardOutput, error: .standardError) } swiftReady = true } else if try await fs.exists(atPath: "./post-install.sh") { @@ -150,9 +158,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, output: .standardOutput, error: .standardError) } 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, output: .standardOutput, error: .standardError) } // Test self-uninstall functionality @@ -160,23 +168,23 @@ 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: SystemPackage.FilePath?, shell: SystemPackage.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, output: .standardOutput, error: .standardError) // 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, output: .standardOutput, error: .standardError) // Verify cleanup for default location try await self.verifyDefaultLocationCleanup(shell: shell, env: env) } } - private func verifyCustomLocationCleanup(customLoc: FilePath) async throws { + private func verifyCustomLocationCleanup(customLoc: SystemPackage.FilePath) async throws { print("Verifying cleanup for custom location at \(customLoc)") // Check that swiftly binary is removed @@ -204,7 +212,7 @@ struct TestSwiftly: AsyncParsableCommand { print("✓ Custom location cleanup verification passed") } - private func verifyDefaultLocationCleanup(shell: FilePath, env: [String: String]) async throws { + private func verifyDefaultLocationCleanup(shell: SystemPackage.FilePath, env: Environment) async throws { print("Verifying cleanup for default location") let swiftlyHome = fs.home / ".swiftly" @@ -237,7 +245,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, output: .standardOutput, error: .standardError) throw TestError("swiftly command is still available in PATH after uninstall") } catch { // Expected - swiftly should not be found @@ -249,7 +257,7 @@ struct TestSwiftly: AsyncParsableCommand { private func verifyProfileCleanup() async throws { print("Verifying shell profile cleanup") - let profilePaths: [FilePath] = [ + let profilePaths: [SystemPackage.FilePath] = [ fs.home / ".zprofile", fs.home / ".bash_profile", fs.home / ".bash_login", diff --git a/Tests/SwiftlyTests/CommandLineTests.swift b/Tests/SwiftlyTests/CommandLineTests.swift index 8f33bfac..c878743c 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 await run(.name("git"), arguments: ["-C", "\(tmp)", "add", "foo.txt"], output: .discarded) + try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "user.email", "user@example.com"], output: .discarded) + try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "commit.gpgsign", "false"], output: .discarded) + 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..d2a687ba 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 @@ -9,6 +10,7 @@ import Testing #if os(macOS) import MacOSPlatform +import System #endif import AsyncHTTPClient @@ -25,24 +27,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 } } @@ -86,7 +73,7 @@ extension Config { extension SwiftlyCoreContext { public init( - mockedHomeDir: FilePath?, + mockedHomeDir: SystemPackage.FilePath?, httpRequestExecutor: HTTPRequestExecutor, outputHandler: (any OutputHandler)?, inputProvider: (any InputProvider)?, @@ -297,7 +284,7 @@ public enum SwiftlyTests { return await handler.lines } - static func getTestHomePath(name: String) -> FilePath { + static func getTestHomePath(name: String) -> SystemPackage.FilePath { fs.tmp / "swiftly-tests-\(name)-\(UUID())" } @@ -474,7 +461,7 @@ public enum SwiftlyTests { } /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given FilePath. - static func getMockedToolchainVersion(at path: FilePath) throws -> ToolchainVersion { + static func getMockedToolchainVersion(at path: SystemPackage.FilePath) throws -> ToolchainVersion { let process = Process() process.executableURL = URL(fileURLWithPath: path.string) @@ -525,7 +512,7 @@ public actor TestInputProvider: SwiftlyCore.InputProvider { /// Wrapper around a `swift` executable used to execute swift commands. public struct SwiftExecutable { - public let path: FilePath + public let path: SystemPackage.FilePath private static func stableRegex() -> Regex<(Substring, Substring)> { try! Regex("swift-([^-]+)-RELEASE") @@ -998,7 +985,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 +1032,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 8c765037..17315051 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -4,6 +4,7 @@ import Foundation import NIOFileSystem import SwiftlyCore import SystemPackage +import Subprocess #if os(macOS) import MacOSPlatform @@ -22,12 +23,12 @@ 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(quiet: Bool = false) async throws { let config = self.config() // if !quiet { print("\(args.joined(separator: " "))") } if !quiet { print("\(config)") } - try await self.run(platform) + try await self.run() } } @@ -111,12 +112,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").run() } 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 +136,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().run() // Build a specific version of libarchive with a check on the tarball's SHA256 let libArchiveVersion = "3.8.1" @@ -166,19 +167,19 @@ 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")).run() 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 swiftVerOutput = (try await Subprocess.run(.name("swift"), arguments: ["--version"], output: .string(limit: 1024))).standardOutput ?? "" guard let swiftVerMatch = try swiftVerRegex.firstMatch(in: swiftVerOutput) else { throw Error(message: "Unable to detect swift version") } @@ -200,46 +201,53 @@ 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 await 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").run(output: .standardOutput, error: .standardError) + + 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")", + ]) + + _ = try await Subprocess.run( + .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, + input: .standardInput, + output: .standardOutput, + error: .standardError, ) - try await sys.make().run(currentPlatform, env: customEnv) + _ = try await sys.make().run(environment: customEnv, output: .standardOutput, error: .standardError) - try await sys.make().install().run(currentPlatform) + try await sys.make().install().run() 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")).run() 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").run() try await self.collectLicenses(releaseDir) @@ -249,7 +257,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"]).run() print(releaseArchive) @@ -262,23 +270,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")).run() + try await sys.tar(.directory(debugDir)).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).run() 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().run() 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)")).run() + try await sys.strip(name: FilePath(".build") / "\(arch)-apple-macosx/release/swiftly").run() } let swiftlyBinDir = fs.cwd / ".build/release/.swiftly/bin" @@ -288,7 +296,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 +315,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 +323,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 +332,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 +350,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 +360,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"]).run() print(testArchive) } diff --git a/Tools/generate-command-models/GenerateCommandModels.swift b/Tools/generate-command-models/GenerateCommandModels.swift index 0c88265b..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,17 +277,36 @@ struct GenerateCommandModels: AsyncParsableCommand { """ } - let configFunc: String + let argumentsFunc: String if path.count == 0 { - configFunc = """ - public func config() -> Configuration { + 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 { + configFunc = """ + public func config() -> Configuration { return Configuration( executable: self.executable, - arguments: Arguments(genArgs), + arguments: Arguments(self.commandArgs()), environment: .inherit ) } @@ -296,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 } @@ -337,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) From 48c1b257078e4adb442004875155f65e0e877830 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 31 Oct 2025 11:41:18 -0400 Subject: [PATCH 10/16] Rigorously check statuses and echo commands in build swiftly release script --- Sources/LinuxPlatform/Linux.swift | 2 - Sources/TestSwiftly/TestSwiftly.swift | 38 ++++++++---- .../BuildSwiftlyRelease.swift | 60 +++++++++++-------- 3 files changed, 63 insertions(+), 37 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 395c019b..8fadb057 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -282,8 +282,6 @@ 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(environment: .inherit.updating(["GNUPGHOME": (mockedHomeDir / ".gnupg").string]), quiet: true) } else { try await sys.gpg()._import(key: tmpFile).run(quiet: true) diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index 91d7037e..d797ef56 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -101,9 +101,9 @@ struct TestSwiftly: AsyncParsableCommand { print("Extracting swiftly release") #if os(Linux) - try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchive)).run(quiet: false) + try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchive)).run() #elseif os(macOS) - try await sys.installer(.verbose, .pkg(swiftlyArchive), .target("CurrentUserHomeDirectory")).run(quiet: false) + try await sys.installer(.verbose, .pkg(swiftlyArchive), .target("CurrentUserHomeDirectory")).run() #endif #if os(Linux) @@ -127,8 +127,16 @@ struct TestSwiftly: AsyncParsableCommand { "SWIFTLY_TOOLCHAINS_DIR": (customLoc! / "toolchains").string, ]) - _ = try await Subprocess.run(.path(extractedSwiftly), arguments: ["init", "--assume-yes", "--no-modify-profile", "--skip-install"], environment: env, input: .standardInput, output: .standardOutput, error: .standardError) - _ = 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, output: .standardOutput, error: .standardError) + 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 @@ -140,8 +148,16 @@ struct TestSwiftly: AsyncParsableCommand { env = env.updating(["XDG_CONFIG_HOME": (fs.home / ".config").string]) } - _ = try await Subprocess.run(.path(extractedSwiftly), arguments: ["init", "--assume-yes", "--skip-install"], environment: env, input: .standardInput, output: .standardOutput, error: .standardError) - _ = try await sh(executable: .path(shell), .login, .command("swiftly install --assume-yes latest --post-install-file=./post-install.sh")).run(environment: env, output: .standardOutput, error: .standardError) + 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 @@ -158,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(environment: env, output: .standardOutput, error: .standardError) + 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(environment: env, output: .standardOutput, error: .standardError) + try await sh(executable: .path(shell), .login, .command("swift --version")).run(environment: env) } // Test self-uninstall functionality @@ -171,13 +187,13 @@ struct TestSwiftly: AsyncParsableCommand { private func testSelfUninstall(customLoc: SystemPackage.FilePath?, shell: SystemPackage.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(environment: env, output: .standardOutput, error: .standardError) + 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(environment: env, output: .standardOutput, error: .standardError) + 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) @@ -245,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(environment: env, output: .standardOutput, error: .standardError) + 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/Tools/build-swiftly-release/BuildSwiftlyRelease.swift b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift index 17315051..8c035353 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -2,9 +2,9 @@ import ArgumentParser import AsyncHTTPClient import Foundation import NIOFileSystem +import Subprocess import SwiftlyCore import SystemPackage -import Subprocess #if os(macOS) import MacOSPlatform @@ -23,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(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() + try await self.run(environment: environment, quiet: quiet) } } @@ -117,7 +115,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { } do { - try await sys.git().diffindex(.quiet, tree_ish: "HEAD").run() + 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.") } @@ -136,7 +134,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { try await self.checkGitRepoStatus() // Start with a fresh SwiftPM package - try await sys.swift().package().reset().run() + 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" @@ -172,14 +170,20 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { 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() + 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 Subprocess.run(.name("swift"), arguments: ["--version"], output: .string(limit: 1024))).standardOutput ?? "" + let swiftVersionCmd = Configuration( + .name("swift"), + arguments: ["--version"] + ) + print("\(swiftVersionCmd)") + + 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") } @@ -201,15 +205,15 @@ 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(output: .standardOutput, error: .standardError) + 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")", + "MUSL_PREFIX": "\(fs.home / ".swiftpm/swift-sdks/\(sdkName).artifactbundle/\(sdkName)/swift-linux-musl/musl-1.2.5.sdk/\(arch)/usr")", ]) - _ = try await Subprocess.run( + let configCmd = Configuration( .path(FilePath("./configure")), arguments: [ "--prefix=\(pkgConfigPath)", @@ -231,23 +235,31 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { "--disable-bsdcat", ], environment: customEnv, - input: .standardInput, + ) + print("\(configCmd)") + + let result = try await Subprocess.run( + configCmd, output: .standardOutput, error: .standardError, ) - _ = try await sys.make().run(environment: customEnv, output: .standardOutput, error: .standardError) + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: configCmd) + } + + try await sys.make().runEcho(environment: customEnv) - try await sys.make().install().run() + 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() + 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() + try await sys.strip(name: releaseDir / "swiftly").runEcho() try await self.collectLicenses(releaseDir) @@ -257,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() + try await sys.tar(.directory(releaseDir)).create(.compressed, .archive(releaseArchive), files: ["swiftly", "LICENSE.txt"]).runEcho() print(releaseArchive) @@ -270,8 +282,8 @@ 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() - try await sys.tar(.directory(debugDir)).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).run() + 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) } @@ -282,11 +294,11 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { func buildMacOSRelease(cert: String?, identifier: String) async throws { try await self.checkGitRepoStatus() - try await sys.swift().package().clean().run() + 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() - try await sys.strip(name: FilePath(".build") / "\(arch)-apple-macosx/release/swiftly").run() + 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" @@ -362,7 +374,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { .create(.output(swiftlyBinDir / "swiftly")) .runEcho() - try await sys.tar(.directory(".build/x86_64-apple-macosx/debug")).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).run() + try await sys.tar(.directory(".build/x86_64-apple-macosx/debug")).create(.compressed, .archive(testArchive), files: ["test-swiftly"]).runEcho() print(testArchive) } From 2c4b1302ee7f034db239b4ecde8db5923c342662 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 31 Oct 2025 12:01:05 -0400 Subject: [PATCH 11/16] Make the command echo less verbose --- Tools/build-swiftly-release/BuildSwiftlyRelease.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift index 8c035353..d4c7bbe6 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -25,7 +25,7 @@ extension Runnable { // Runs the command while echoing the full command-line to stdout for logging and reproduction func runEcho(environment: Environment = .inherit, quiet: Bool = false) async throws { let config = self.config() - if !quiet { print("\(config)") } + if !quiet { print("\(config.executable) \(config.arguments)") } try await self.run(environment: environment, quiet: quiet) } } @@ -181,7 +181,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { .name("swift"), arguments: ["--version"] ) - print("\(swiftVersionCmd)") + 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 { @@ -236,7 +236,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { ], environment: customEnv, ) - print("\(configCmd)") + print("\(configCmd.executable) \(configCmd.arguments)") let result = try await Subprocess.run( configCmd, From fc7beeaaf22ca854041f5f80db3c0bf582b4b448 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 31 Oct 2025 15:22:57 -0400 Subject: [PATCH 12/16] Remove unnecessary duplicate imports of System and SystemPackage --- Sources/MacOSPlatform/MacOS.swift | 29 ++++++++++++++++----------- Sources/TestSwiftly/TestSwiftly.swift | 22 +++++++++----------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index b5bb9740..8bd7fae7 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -1,7 +1,6 @@ import Foundation import Subprocess import SwiftlyCore -import System import SystemPackage typealias sys = SwiftlyCore.SystemCommand @@ -19,21 +18,21 @@ public struct SwiftPkgInfo: Codable { public struct MacOS: Platform { public init() {} - public var defaultSwiftlyHomeDir: SystemPackage.FilePath { + public var defaultSwiftlyHomeDir: FilePath { fs.home / ".swiftly" } - public var defaultToolchainsDirectory: SystemPackage.FilePath { + public var defaultToolchainsDirectory: FilePath { fs.home / "Library/Developer/Toolchains" } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { ctx.mockedHomeDir.map { $0 / "bin" } ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } ?? fs.home / ".swiftly/bin" } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> SystemPackage.FilePath { + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { ctx.mockedHomeDir.map { $0 / "Toolchains" } ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { FilePath($0) } // This is where the installer will put the toolchains, and where Xcode can find them @@ -57,7 +56,7 @@ public struct MacOS: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: SystemPackage.FilePath, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool ) async throws { guard try await fs.exists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") @@ -110,7 +109,7 @@ public struct MacOS: Platform { } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: SystemPackage.FilePath) async throws { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { guard try await fs.exists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } @@ -144,7 +143,13 @@ public struct MacOS: Platform { try await sys.tar(.directory(installDir)).extract(.verbose, .archive(payload)).run(quiet: false) } - _ = try await run(.path(System.FilePath((userHomeDir / ".swiftly/bin/swiftly").string)), arguments: ["init"], input: .standardInput, output: .standardOutput, error: .standardError) + 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) @@ -174,14 +179,14 @@ public struct MacOS: Platform { } public func verifyToolchainSignature( - _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: SystemPackage.FilePath, verbose _: Bool + _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. } public func verifySwiftlySignature( - _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: SystemPackage.FilePath, verbose _: Bool + _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. @@ -203,11 +208,11 @@ public struct MacOS: Platform { return "/bin/zsh" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> SystemPackage.FilePath + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath { if toolchain == .xcodeVersion { // Print the toolchain location with the help of xcrun - if let xcrunLocation = try? await run(.path(SystemPackage.FilePath("/usr/bin/xcrun")), arguments: ["-f", "swift"], output: .string(limit: 1024 * 10)).standardOutput { + 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/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index d797ef56..c46018ef 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -4,10 +4,6 @@ import Subprocess import SwiftlyCore import SystemPackage -#if os(macOS) -import System -#endif - #if os(Linux) import LinuxPlatform #elseif os(macOS) @@ -97,7 +93,7 @@ struct TestSwiftly: AsyncParsableCommand { Foundation.exit(2) } - guard case let swiftlyArchive = SystemPackage.FilePath(swiftlyArchive) else { fatalError("") } + guard case let swiftlyArchive = FilePath(swiftlyArchive) else { fatalError("") } print("Extracting swiftly release") #if os(Linux) @@ -107,14 +103,14 @@ struct TestSwiftly: AsyncParsableCommand { #endif #if os(Linux) - let extractedSwiftly = SystemPackage.FilePath("./swiftly") + let extractedSwiftly = FilePath("./swiftly") #elseif os(macOS) - let extractedSwiftly = System.FilePath((fs.home / ".swiftly/bin/swiftly").string) + let extractedSwiftly = FilePath((fs.home / ".swiftly/bin/swiftly").string) #endif var env: Environment = .inherit - let shell = SystemPackage.FilePath(try await currentPlatform.getShell()) - var customLoc: SystemPackage.FilePath? + let shell = FilePath(try await currentPlatform.getShell()) + var customLoc: FilePath? if self.customLocation { customLoc = fs.mktemp() @@ -184,7 +180,7 @@ struct TestSwiftly: AsyncParsableCommand { try await self.testSelfUninstall(customLoc: customLoc, shell: shell, env: env) } - private func testSelfUninstall(customLoc: SystemPackage.FilePath?, shell: SystemPackage.FilePath, env: Environment) 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(environment: env) @@ -200,7 +196,7 @@ struct TestSwiftly: AsyncParsableCommand { } } - private func verifyCustomLocationCleanup(customLoc: SystemPackage.FilePath) async throws { + private func verifyCustomLocationCleanup(customLoc: FilePath) async throws { print("Verifying cleanup for custom location at \(customLoc)") // Check that swiftly binary is removed @@ -228,7 +224,7 @@ struct TestSwiftly: AsyncParsableCommand { print("✓ Custom location cleanup verification passed") } - private func verifyDefaultLocationCleanup(shell: SystemPackage.FilePath, env: Environment) async throws { + private func verifyDefaultLocationCleanup(shell: FilePath, env: Environment) async throws { print("Verifying cleanup for default location") let swiftlyHome = fs.home / ".swiftly" @@ -273,7 +269,7 @@ struct TestSwiftly: AsyncParsableCommand { private func verifyProfileCleanup() async throws { print("Verifying shell profile cleanup") - let profilePaths: [SystemPackage.FilePath] = [ + let profilePaths: [FilePath] = [ fs.home / ".zprofile", fs.home / ".bash_profile", fs.home / ".bash_login", From 7186f09a5676db653a56aa872a01549df736a71c Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 31 Oct 2025 15:35:17 -0400 Subject: [PATCH 13/16] Remove unnecessary system imports for MacOS and redundant qualifications of FilePath --- Sources/Swiftly/Proxy.swift | 12 ++++++++++-- Sources/TestSwiftly/TestSwiftly.swift | 12 ++++++++---- Tests/SwiftlyTests/SwiftlyTests.swift | 9 ++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift index 51d9954d..6dd4bdeb 100644 --- a/Sources/Swiftly/Proxy.swift +++ b/Sources/Swiftly/Proxy.swift @@ -71,14 +71,22 @@ public enum Proxy { let env = try await Swiftly.currentPlatform.proxyEnvironment(ctx, env: .inherit, toolchain: toolchain) - _ = try await Subprocess.run( + let cmdConfig = Configuration( .name(binName), arguments: Arguments(Array(CommandLine.arguments[1...])), - environment: env.updating(["SWIFTLY_PROXY_IN_PROGRESS": "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 { switch terminated.terminationStatus { case let .exited(code): diff --git a/Sources/TestSwiftly/TestSwiftly.swift b/Sources/TestSwiftly/TestSwiftly.swift index c46018ef..cd6f7ee5 100644 --- a/Sources/TestSwiftly/TestSwiftly.swift +++ b/Sources/TestSwiftly/TestSwiftly.swift @@ -93,13 +93,13 @@ struct TestSwiftly: AsyncParsableCommand { Foundation.exit(2) } - guard case let swiftlyArchive = FilePath(swiftlyArchive) else { fatalError("") } + let swiftlyArchiveFile = FilePath(swiftlyArchive) print("Extracting swiftly release") #if os(Linux) - try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchive)).run() + try await sys.tar().extract(.verbose, .compressed, .archive(swiftlyArchiveFile)).run() #elseif os(macOS) - try await sys.installer(.verbose, .pkg(swiftlyArchive), .target("CurrentUserHomeDirectory")).run() + try await sys.installer(.verbose, .pkg(swiftlyArchiveFile), .target("CurrentUserHomeDirectory")).run() #endif #if os(Linux) @@ -160,7 +160,11 @@ struct TestSwiftly: AsyncParsableCommand { if NSUserName() == "root" { if try await fs.exists(atPath: "./post-install.sh") { - _ = try await Subprocess.run(.path(shell), arguments: ["./post-install.sh"], input: .standardInput, output: .standardOutput, error: .standardError) + 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") { diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index d2a687ba..e9c10f65 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -10,7 +10,6 @@ import Testing #if os(macOS) import MacOSPlatform -import System #endif import AsyncHTTPClient @@ -73,7 +72,7 @@ extension Config { extension SwiftlyCoreContext { public init( - mockedHomeDir: SystemPackage.FilePath?, + mockedHomeDir: FilePath?, httpRequestExecutor: HTTPRequestExecutor, outputHandler: (any OutputHandler)?, inputProvider: (any InputProvider)?, @@ -284,7 +283,7 @@ public enum SwiftlyTests { return await handler.lines } - static func getTestHomePath(name: String) -> SystemPackage.FilePath { + static func getTestHomePath(name: String) -> FilePath { fs.tmp / "swiftly-tests-\(name)-\(UUID())" } @@ -461,7 +460,7 @@ public enum SwiftlyTests { } /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given FilePath. - static func getMockedToolchainVersion(at path: SystemPackage.FilePath) throws -> ToolchainVersion { + static func getMockedToolchainVersion(at path: FilePath) throws -> ToolchainVersion { let process = Process() process.executableURL = URL(fileURLWithPath: path.string) @@ -512,7 +511,7 @@ public actor TestInputProvider: SwiftlyCore.InputProvider { /// Wrapper around a `swift` executable used to execute swift commands. public struct SwiftExecutable { - public let path: SystemPackage.FilePath + public let path: FilePath private static func stableRegex() -> Regex<(Substring, Substring)> { try! Regex("swift-([^-]+)-RELEASE") From 3ed18b6225053875abf4c409be2809233b70de2b Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sat, 1 Nov 2025 08:16:26 -0400 Subject: [PATCH 14/16] Rework output methods to allow quiet and async sequence variants --- Sources/SwiftlyCore/ModeledCommandLine.swift | 79 ++++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/Sources/SwiftlyCore/ModeledCommandLine.swift b/Sources/SwiftlyCore/ModeledCommandLine.swift index 9a7cfcdf..abd22aed 100644 --- a/Sources/SwiftlyCore/ModeledCommandLine.swift +++ b/Sources/SwiftlyCore/ModeledCommandLine.swift @@ -2,12 +2,6 @@ import Foundation import Subprocess import SystemPackage -public enum CommandLineError: Error { - case invalidArgs - case errorExit(exitCode: Int32, program: String) - case unknownVersion -} - public protocol Runnable { func config() -> Configuration } @@ -24,6 +18,7 @@ extension Runnable { 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 @@ -40,6 +35,7 @@ extension Runnable { 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 @@ -59,23 +55,78 @@ extension Runnable { public protocol Output: Runnable {} -// TODO: look into making this something that can be Decodable (i.e. streamable) extension Output { public func output( environment: Environment = .inherit, - limit: Int + 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 - let output = try await Subprocess.run( - self.config(), - output: .string(limit: limit), - error: .standardError - ) + if !quiet { + let result = try await Subprocess.run( + self.config(), + output: .string(limit: limit), + error: .standardError + ) - return output.standardOutput + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } + + return result.standardOutput + } else { + let result = try await Subprocess.run( + self.config(), + output: .string(limit: limit), + error: .discarded + ) + + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } + + return result.standardOutput + } + } + + public func output( + environment: Environment = .inherit, + limit _: Int, + quiet: Bool = false, + body: (AsyncBufferSequence) -> Void + ) 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 + + if !quiet { + let result = try await Subprocess.run( + self.config(), + error: .standardError + ) { _, sequence in + body(sequence) + } + + 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) + } + + if !result.terminationStatus.isSuccess { + throw RunProgramError(terminationStatus: result.terminationStatus, config: c) + } + } } } From 2e9f2022aee92e8cdf1ac19dafd1ae472c8ad381 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sat, 1 Nov 2025 08:34:36 -0400 Subject: [PATCH 15/16] Short fixes --- Sources/MacOSPlatform/MacOS.swift | 2 +- Tests/SwiftlyTests/CommandLineTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index 8bd7fae7..b68f1ca1 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -95,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() + 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. diff --git a/Tests/SwiftlyTests/CommandLineTests.swift b/Tests/SwiftlyTests/CommandLineTests.swift index c878743c..e9484041 100644 --- a/Tests/SwiftlyTests/CommandLineTests.swift +++ b/Tests/SwiftlyTests/CommandLineTests.swift @@ -142,9 +142,9 @@ public struct CommandLineTests { // AND a simple history try "Some text".write(to: tmp / "foo.txt", atomically: true) - try await run(.name("git"), arguments: ["-C", "\(tmp)", "add", "foo.txt"], output: .discarded) - try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "user.email", "user@example.com"], output: .discarded) - try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "commit.gpgsign", "false"], output: .discarded) + #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "add", "foo.txt"], output: .standardOutput).terminationStatus.isSuccess) + #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "user.email", "user@example.com"], output: .standardOutput).terminationStatus.isSuccess) + #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() From 4d7afee5b75290e5fae2c22be6b3a06c375ad503 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sat, 1 Nov 2025 08:42:20 -0400 Subject: [PATCH 16/16] Fix compile error --- Tests/SwiftlyTests/CommandLineTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/SwiftlyTests/CommandLineTests.swift b/Tests/SwiftlyTests/CommandLineTests.swift index e9484041..94d14d2b 100644 --- a/Tests/SwiftlyTests/CommandLineTests.swift +++ b/Tests/SwiftlyTests/CommandLineTests.swift @@ -142,9 +142,9 @@ public struct CommandLineTests { // AND a simple history try "Some text".write(to: tmp / "foo.txt", atomically: true) - #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "add", "foo.txt"], output: .standardOutput).terminationStatus.isSuccess) - #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "user.email", "user@example.com"], output: .standardOutput).terminationStatus.isSuccess) - #require(try await run(.name("git"), arguments: ["-C", "\(tmp)", "config", "--local", "commit.gpgsign", "false"], output: .standardOutput).terminationStatus.isSuccess) + 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()