From dd1d29cf49b200b0c60b183a821a1feffe55e47d Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Wed, 13 Aug 2025 16:23:50 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 {