diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 22b18830..9e4871cb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test + run: SWIFT_DEBUG_FAILED_TYPE_LOOKUP=y swift test --no-parallel --filter HTTPClientTests releasebuildcheck: name: Release Build Check diff --git a/Package.swift b/Package.swift index acb0a783..5cde1ada 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.6.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.7.0"), + .package(url: "https://github.com/apple/swift-system", from: "1.4.2"), // 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"), ], @@ -38,6 +39,7 @@ let package = Package( .target(name: "LinuxPlatform", condition: .when(platforms: [.linux])), .target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), + .product(name: "SystemPackage", package: "swift-system"), ] ), .executableTarget( @@ -58,6 +60,7 @@ let package = Package( .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIAsyncHTTPClient", package: "swift-openapi-async-http-client"), + .product(name: "SystemPackage", package: "swift-system"), ], ), .target( @@ -114,6 +117,7 @@ let package = Package( dependencies: [ "SwiftlyCore", "CLibArchive", + .product(name: "SystemPackage", package: "swift-system"), ], linkerSettings: [ .linkedLibrary("z"), @@ -123,6 +127,7 @@ let package = Package( name: "MacOSPlatform", dependencies: [ "SwiftlyCore", + .product(name: "SystemPackage", package: "swift-system"), ] ), .systemLibrary( @@ -134,7 +139,10 @@ let package = Package( ), .testTarget( name: "SwiftlyTests", - dependencies: ["Swiftly"], + dependencies: [ + "Swiftly", + .product(name: "SystemPackage", package: "swift-system"), + ], resources: [ .embedInCode("mock-signing-key-private.pgp"), ] diff --git a/Sources/LinuxPlatform/Extract.swift b/Sources/LinuxPlatform/Extract.swift index c3a4973f..28e8fa62 100644 --- a/Sources/LinuxPlatform/Extract.swift +++ b/Sources/LinuxPlatform/Extract.swift @@ -1,5 +1,6 @@ import CLibArchive import Foundation +import SystemPackage // The code in this file consists mainly of a Swift port of the "Complete Extractor" example included in the libarchive // documentation: https://github.com/libarchive/libarchive/wiki/Examples#a-complete-extractor @@ -44,7 +45,7 @@ func copyData(readArchive: OpaquePointer?, writeArchive: OpaquePointer?) throws /// the provided closure which will return the path the file will be written to. /// /// This uses libarchive under the hood, so a wide variety of archive formats are supported (e.g. .tar.gz). -func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws { +func extractArchive(atPath archivePath: FilePath, transform: (String) -> FilePath) throws { var flags = Int32(0) flags = ARCHIVE_EXTRACT_TIME flags |= ARCHIVE_EXTRACT_PERM @@ -66,8 +67,8 @@ func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws archive_write_free(ext) } - if archive_read_open_filename(a, archivePath.path, 10240) != 0 { - throw ExtractError(message: "Failed to open \"\(archivePath.path)\"") + if archive_read_open_filename(a, archivePath.string, 10240) != 0 { + throw ExtractError(message: "Failed to open \"\(archivePath)\"") } while true { @@ -82,7 +83,7 @@ func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws } let currentPath = String(cString: archive_entry_pathname(entry)) - archive_entry_set_pathname(entry, transform(currentPath).path) + archive_entry_set_pathname(entry, transform(currentPath).string) r = archive_write_header(ext, entry) guard r == ARCHIVE_OK else { throw ExtractError(archive: ext) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 682bc23b..6cfa843c 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyCore +import SystemPackage /// `Platform` implementation for Linux systems. /// This implementation can be reused for any supported Linux platform. @@ -18,24 +19,22 @@ public struct Linux: Platform { public init() {} - public var defaultSwiftlyHomeDirectory: URL { + public var defaultSwiftlyHomeDir: FilePath { if let dir = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] { - return URL(fileURLWithPath: dir).appendingPathComponent("swiftly", isDirectory: true) + return FilePath(dir) / "swiftly" } else { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/swiftly", isDirectory: true) + return homeDir / ".local/share/swiftly" } } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } - ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/swiftly/bin", isDirectory: true) + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "bin" } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } + ?? homeDir / ".local/share/swiftly/bin" } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { - self.swiftlyHomeDir(ctx).appendingPathComponent("toolchains", isDirectory: true) + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { + self.swiftlyHomeDir(ctx) / "toolchains" } public var toolchainFileExtension: String { @@ -45,12 +44,12 @@ public struct Linux: Platform { private static let skipVerificationMessage: String = "To skip signature verification, specify the --no-verify flag." - public func verifySwiftlySystemPrerequisites() throws { + public func verifySwiftlySystemPrerequisites() async throws { // Check if the root CA certificates are installed on this system for NIOSSL to use. // This list comes from LinuxCABundle.swift in NIOSSL. var foundTrustedCAs = false for crtFile in ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"] { - if URL(fileURLWithPath: crtFile).fileExists() { + if try await fileExists(atPath: FilePath(crtFile)) { foundTrustedCAs = true break } @@ -267,21 +266,17 @@ public struct Linux: Platform { } let tmpFile = self.getTempFilePath() - let _ = FileManager.default.createFile( - atPath: tmpFile.path, contents: nil, attributes: [.posixPermissions: 0o600] - ) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - try await ctx.httpClient.getGpgKeys().download(to: tmpFile) - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--import", tmpFile.path, quiet: true, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--import", tmpFile.path, quiet: true) + try await create(file: tmpFile, contents: nil, mode: 0o600) + try await withTemporary(files: tmpFile) { + try await ctx.httpClient.getGpgKeys().download(to: tmpFile) + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--import", "\(tmpFile)", quiet: true, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--import", "\(tmpFile)", quiet: true) + } } } @@ -333,23 +328,21 @@ public struct Linux: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool ) async throws { - guard tmpFile.fileExists() else { + guard try await fileExists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory( - at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false - ) + if !(try await fileExists(atPath: self.swiftlyToolchainsDir(ctx))) { + try await mkdir(atPath: self.swiftlyToolchainsDir(ctx)) } await ctx.print("Extracting toolchain...") - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(version.name) + let toolchainDir = self.swiftlyToolchainsDir(ctx) / version.name - if toolchainDir.fileExists() { - try FileManager.default.removeItem(at: toolchainDir) + if try await fileExists(atPath: toolchainDir) { + try await remove(atPath: toolchainDir) } try extractArchive(atPath: tmpFile) { name in @@ -357,13 +350,13 @@ public struct Linux: Platform { let relativePath = name.drop { c in c != "/" }.dropFirst() // prepend /path/to/swiftlyHomeDir/toolchains/ to each file name - let destination = toolchainDir.appendingPathComponent(String(relativePath)) + let destination = toolchainDir / String(relativePath) if verbose { // To avoid having to make extractArchive async this is a regular print // to stdout. Note that it is unlikely that the test mocking will require // capturing this output. - print("\(destination.path)") + print("\(destination)") } // prepend /path/to/swiftlyHomeDir/toolchains/ to each file name @@ -371,31 +364,27 @@ public struct Linux: Platform { } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: URL) async throws { - guard archive.fileExists() else { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { + guard try await fileExists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } let tmpDir = self.getTempFilePath() - defer { - try? FileManager.default.removeItem(at: tmpDir) - } - try FileManager.default.createDirectory(atPath: tmpDir.path, withIntermediateDirectories: true) + try await mkdir(atPath: tmpDir, parents: true) + try await withTemporary(files: tmpDir) { + await ctx.print("Extracting new swiftly...") + try extractArchive(atPath: archive) { name in + // Extract to the temporary directory + tmpDir / String(name) + } - await ctx.print("Extracting new swiftly...") - try extractArchive(atPath: archive) { name in - // Extract to the temporary directory - tmpDir.appendingPathComponent(String(name)) + try self.runProgram((tmpDir / "swiftly").string, "init") } - - try self.runProgram(tmpDir.appendingPathComponent("swiftly").path, "init") } - public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) - throws - { - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(toolchain.name) - try FileManager.default.removeItem(at: toolchainDir) + public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) async throws { + let toolchainDir = self.swiftlyToolchainsDir(ctx) / toolchain.name + try await remove(atPath: toolchainDir) } public func getExecutableName() -> String { @@ -404,69 +393,65 @@ public struct Linux: Platform { return "swiftly-\(arch)-unknown-linux-gnu" } - public func getTempFilePath() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") + public func getTempFilePath() -> FilePath { + tmpDir / "swiftly-\(UUID())" } public func verifyToolchainSignature( - _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: FilePath, verbose: Bool ) async throws { if verbose { await ctx.print("Downloading toolchain signature...") } let sigFile = self.getTempFilePath() - let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: sigFile) - } - - try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile) - - await ctx.print("Verifying toolchain signature...") - do { - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--verify", sigFile.path, archive.path, quiet: false, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) + try await create(file: sigFile, contents: nil) + try await withTemporary(files: sigFile) { + try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile) + + await ctx.print("Verifying toolchain signature...") + do { + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--verify", "\(sigFile)", "\(archive)", quiet: false, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--verify", "\(sigFile)", "\(archive)", quiet: !verbose) + } + } catch { + throw SwiftlyError(message: "Signature verification failed: \(error).") } - } catch { - throw SwiftlyError(message: "Signature verification failed: \(error).") } } public func verifySwiftlySignature( - _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool ) async throws { if verbose { await ctx.print("Downloading swiftly signature...") } let sigFile = self.getTempFilePath() - let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: sigFile) - } - - try await ctx.httpClient.getSwiftlyReleaseSignature( - url: archiveDownloadURL.appendingPathExtension("sig") - ).download(to: sigFile) - - await ctx.print("Verifying swiftly signature...") - do { - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--verify", sigFile.path, archive.path, quiet: false, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) + try await create(file: sigFile, contents: nil) + try await withTemporary(files: sigFile) { + try await ctx.httpClient.getSwiftlyReleaseSignature( + url: archiveDownloadURL.appendingPathExtension("sig") + ).download(to: sigFile) + + await ctx.print("Verifying swiftly signature...") + do { + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--verify", "\(sigFile)", "\(archive)", quiet: false, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--verify", "\(sigFile)", "\(archive)", quiet: !verbose) + } + } catch { + throw SwiftlyError(message: "Signature verification failed: \(error).") } - } catch { - throw SwiftlyError(message: "Signature verification failed: \(error).") } } @@ -631,9 +616,9 @@ public struct Linux: Platform { return "/bin/bash" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.name)") + self.swiftlyToolchainsDir(ctx) / "\(toolchain.name)" } public static let currentPlatform: any Platform = Linux() diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index d6b73825..fbcc36d7 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyCore +import SystemPackage public struct SwiftPkgInfo: Codable { public var CFBundleIdentifier: String @@ -13,24 +14,20 @@ public struct SwiftPkgInfo: Codable { public struct MacOS: Platform { public init() {} - public var defaultSwiftlyHomeDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".swiftly", isDirectory: true) + public var defaultSwiftlyHomeDir: FilePath { + homeDir / ".swiftly" } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } - ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".swiftly/bin", isDirectory: true) + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "bin" } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } + ?? (homeDir / ".swiftly/bin") } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) } + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "Toolchains" } // The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - "Library/Developer/Toolchains", isDirectory: true - ) + ?? homeDir / "Library/Developer/Toolchains" } public var toolchainFileExtension: String { @@ -50,88 +47,76 @@ public struct MacOS: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool ) async throws { - guard tmpFile.fileExists() else { + guard try await fileExists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory( - at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false - ) + if !(try await fileExists(atPath: self.swiftlyToolchainsDir(ctx))) { + try await mkdir(atPath: self.swiftlyToolchainsDir(ctx)) } if ctx.mockedHomeDir == nil { await ctx.print("Installing package in user home directory...") try runProgram( - "installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", + "installer", "-verbose", "-pkg", "\(tmpFile)", "-target", "CurrentUserHomeDirectory", quiet: !verbose ) } else { // 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. await ctx.print("Expanding pkg...") - let tmpDir = self.getTempFilePath() - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( - "\(version.identifier).xctoolchain", isDirectory: true - ) - if !toolchainDir.fileExists() { - try FileManager.default.createDirectory( - at: toolchainDir, withIntermediateDirectories: false - ) + let tmpDir = mktemp() + let toolchainDir = self.swiftlyToolchainsDir(ctx) / "\(version.identifier).xctoolchain" + if !(try await fileExists(atPath: toolchainDir)) { + try await mkdir(atPath: toolchainDir) } - try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose) + try runProgram("pkgutil", "--verbose", "--expand", "\(tmpFile)", "\(tmpDir)", 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. - var payload = tmpDir.appendingPathComponent("Payload") - if !payload.fileExists() { - payload = tmpDir.appendingPathComponent("\(version.identifier)-osx-package.pkg/Payload") + var payload = tmpDir / "Payload" + if !(try await fileExists(atPath: payload)) { + payload = tmpDir / "\(version.identifier)-osx-package.pkg/Payload" } await ctx.print("Untarring pkg Payload...") - try runProgram("tar", "-C", toolchainDir.path, "-xvf", payload.path, quiet: !verbose) + try runProgram("tar", "-C", "\(toolchainDir)", "-xvf", "\(payload)", quiet: !verbose) } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: URL) async throws { - guard archive.fileExists() else { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { + guard try await fileExists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } - let homeDir: URL + let userHomeDir = ctx.mockedHomeDir ?? homeDir if ctx.mockedHomeDir == nil { - homeDir = FileManager.default.homeDirectoryForCurrentUser - await ctx.print("Extracting the swiftly package...") - try runProgram("installer", "-pkg", archive.path, "-target", "CurrentUserHomeDirectory") - try? runProgram("pkgutil", "--volume", homeDir.path, "--forget", "org.swift.swiftly") + try runProgram("installer", "-pkg", "\(archive)", "-target", "CurrentUserHomeDirectory") + try? runProgram("pkgutil", "--volume", "\(userHomeDir)", "--forget", "org.swift.swiftly") } else { - homeDir = ctx.mockedHomeDir ?? FileManager.default.homeDirectoryForCurrentUser - - let installDir = homeDir.appendingPathComponent(".swiftly") - try FileManager.default.createDirectory( - atPath: installDir.path, withIntermediateDirectories: true - ) + let installDir = userHomeDir / ".swiftly" + try await mkdir(atPath: installDir, parents: true) // 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 = self.getTempFilePath() - try runProgram("pkgutil", "--expand", archive.path, tmpDir.path) + let tmpDir = mktemp() + try runProgram("pkgutil", "--expand", "\(archive)", "\(tmpDir)") // 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. - let payload = tmpDir.appendingPathComponent("Payload") - guard payload.fileExists() else { + let payload = tmpDir / "Payload" + guard try await fileExists(atPath: payload) else { throw SwiftlyError(message: "Payload file could not be found at \(tmpDir).") } - await ctx.print("Extracting the swiftly package into \(installDir.path)...") - try runProgram("tar", "-C", installDir.path, "-xvf", payload.path, quiet: false) + await ctx.print("Extracting the swiftly package into \(installDir)...") + try runProgram("tar", "-C", "\(installDir)", "-xvf", "\(payload)", quiet: false) } - try self.runProgram(homeDir.appendingPathComponent(".swiftly/bin/swiftly").path, "init") + try self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") } public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) @@ -139,25 +124,20 @@ public struct MacOS: Platform { { await ctx.print("Uninstalling package in user home directory...") - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( - "\(toolchain.identifier).xctoolchain", isDirectory: true - ) + let toolchainDir = self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain" let decoder = PropertyListDecoder() - let infoPlist = toolchainDir.appendingPathComponent("Info.plist") - guard let data = try? Data(contentsOf: infoPlist) else { - throw SwiftlyError(message: "could not open \(infoPlist.path)") - } + let infoPlist = toolchainDir / "Info.plist" + let data = try await cat(atPath: infoPlist) guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else { - throw SwiftlyError(message: "could not decode plist at \(infoPlist.path)") + throw SwiftlyError(message: "could not decode plist at \(infoPlist)") } - try FileManager.default.removeItem(at: toolchainDir) + try await remove(atPath: toolchainDir) - let homedir = ProcessInfo.processInfo.environment["HOME"]! try? runProgram( - "pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose + "pkgutil", "--volume", "\(homeDir)", "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose ) } @@ -165,19 +145,15 @@ public struct MacOS: Platform { "swiftly-macos-osx" } - public func getTempFilePath() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") - } - public func verifyToolchainSignature( - _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: URL, 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 _: URL, 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. @@ -191,9 +167,7 @@ public struct MacOS: Platform { } public func getShell() async throws -> String { - if let directoryInfo = try await runProgramOutput( - "dscl", ".", "-read", FileManager.default.homeDirectoryForCurrentUser.path - ) { + if let directoryInfo = try await runProgramOutput("dscl", ".", "-read", "\(homeDir)") { for line in directoryInfo.components(separatedBy: "\n") { if line.hasPrefix("UserShell: ") { if case let comps = line.components(separatedBy: ": "), comps.count == 2 { @@ -207,9 +181,9 @@ public struct MacOS: Platform { return "/bin/zsh" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.identifier).xctoolchain") + self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain" } public static let currentPlatform: any Platform = MacOS() diff --git a/Sources/Swiftly/Config.swift b/Sources/Swiftly/Config.swift index 0be2aa05..910738ae 100644 --- a/Sources/Swiftly/Config.swift +++ b/Sources/Swiftly/Config.swift @@ -5,7 +5,7 @@ import SwiftlyCore /// the current in-use tooolchain, and information about the platform. /// /// TODO: implement cache -public struct Config: Codable, Equatable { +public struct Config: Codable, Equatable, Sendable { public var inUse: ToolchainVersion? public var installedToolchains: Set public var platform: PlatformDefinition @@ -24,9 +24,10 @@ public struct Config: Codable, Equatable { } /// Read the config file from disk. - public static func load(_ ctx: SwiftlyCoreContext) throws -> Config { + public static func load(_ ctx: SwiftlyCoreContext) async throws -> Config { do { - let data = try Data(contentsOf: Swiftly.currentPlatform.swiftlyConfigFile(ctx)) + let configFile = Swiftly.currentPlatform.swiftlyConfigFile(ctx) + let data = try await cat(atPath: configFile) var config = try JSONDecoder().decode(Config.self, from: data) if config.version == nil { // Assume that the version of swiftly is 0.3.0 because that is the last @@ -36,7 +37,7 @@ public struct Config: Codable, Equatable { return config } catch { let msg = """ - Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile(ctx).path). + Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile(ctx)). To begin using swiftly you can install it: '\(CommandLine.arguments[0]) init'. """ @@ -70,8 +71,8 @@ public struct Config: Codable, Equatable { /// Load the config, pass it to the provided closure, and then /// save the modified config to disk. - public static func update(_ ctx: SwiftlyCoreContext, f: (inout Config) throws -> Void) throws { - var config = try Config.load(ctx) + public static func update(_ ctx: SwiftlyCoreContext, f: (inout Config) throws -> Void) async throws { + var config = try await Config.load(ctx) try f(&config) // only save the updates if the prior closure invocation succeeded try config.save(ctx) diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 6afc047b..6640c153 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Init: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -39,9 +40,9 @@ struct Init: SwiftlyCommand { /// Initialize the installation of swiftly. static func execute(_ ctx: SwiftlyCoreContext, assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool) async throws { - try Swiftly.currentPlatform.verifySwiftlySystemPrerequisites() + try await Swiftly.currentPlatform.verifySwiftlySystemPrerequisites() - var config = try? Config.load(ctx) + var config = try? await Config.load(ctx) if var config, !overwrite && ( @@ -79,9 +80,9 @@ struct Init: SwiftlyCommand { Swiftly installs files into the following locations: - \(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path) - Directory for configuration files - \(Swiftly.currentPlatform.swiftlyBinDir(ctx).path) - Links to the binaries of the active toolchain - \(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path) - Directory hosting installed toolchains + \(Swiftly.currentPlatform.swiftlyHomeDir(ctx)) - Directory for configuration files + \(Swiftly.currentPlatform.swiftlyBinDir(ctx)) - Links to the binaries of the active toolchain + \(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) - Directory hosting installed toolchains These locations can be changed by setting the environment variables SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again. @@ -130,36 +131,36 @@ struct Init: SwiftlyCommand { } } - let envFile: URL + let envFile: FilePath let sourceLine: String if shell.hasSuffix("fish") { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx).appendingPathComponent("env.fish", isDirectory: false) + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" sourceLine = """ # Added by swiftly - source "\(envFile.path)" + source "\(envFile)" """ } else { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx).appendingPathComponent("env.sh", isDirectory: false) + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.sh" sourceLine = """ # Added by swiftly - . "\(envFile.path)" + . "\(envFile)" """ } if overwrite { - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyHomeDir(ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyHomeDir(ctx)) } // Go ahead and create the directories as needed for requiredDir in Swiftly.requiredDirectories(ctx) { - if !requiredDir.fileExists() { + if !(try await fileExists(atPath: requiredDir)) { do { - try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + try await mkdir(atPath: requiredDir, parents: true) } catch { - throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir.path)\": \(error)") + throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir)\": \(error)") } } } @@ -179,13 +180,15 @@ struct Init: SwiftlyCommand { // Move our executable over to the correct place try await Swiftly.currentPlatform.installSwiftlyBin(ctx) - if overwrite || !FileManager.default.fileExists(atPath: envFile.path) { + let envFileExists = try await fileExists(atPath: envFile) + + if overwrite || !envFileExists { await ctx.print("Creating shell environment file for the user...") var env = "" if shell.hasSuffix("fish") { env = """ - set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" - set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx))" + set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx))" if not contains "$SWIFTLY_BIN_DIR" $PATH set -x PATH "$SWIFTLY_BIN_DIR" $PATH end @@ -193,8 +196,8 @@ struct Init: SwiftlyCommand { """ } else { env = """ - export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" - export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx))" + export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx))" if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then export PATH="$SWIFTLY_BIN_DIR:$PATH" fi @@ -208,42 +211,38 @@ struct Init: SwiftlyCommand { if !noModifyProfile { await ctx.print("Updating profile...") - let userHome = ctx.mockedHomeDir ?? FileManager.default.homeDirectoryForCurrentUser + let userHome = ctx.mockedHomeDir ?? homeDir - let profileHome: URL + let profileHome: FilePath if shell.hasSuffix("zsh") { - profileHome = userHome.appendingPathComponent(".zprofile", isDirectory: false) + profileHome = userHome / ".zprofile" } else if shell.hasSuffix("bash") { - if case let p = userHome.appendingPathComponent(".bash_profile", isDirectory: false), FileManager.default.fileExists(atPath: p.path) { + if case let p = userHome / ".bash_profile", try await fileExists(atPath: p) { profileHome = p - } else if case let p = userHome.appendingPathComponent(".bash_login", isDirectory: false), FileManager.default.fileExists(atPath: p.path) { + } else if case let p = userHome / ".bash_login", try await fileExists(atPath: p) { profileHome = p } else { - profileHome = userHome.appendingPathComponent(".profile", isDirectory: false) + profileHome = userHome / ".profile" } } else if shell.hasSuffix("fish") { - if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = URL(fileURLWithPath: xdgConfigHome) { - let confDir = xdgConfigURL.appendingPathComponent("fish/conf.d", isDirectory: true) - try FileManager.default.createDirectory(at: confDir, withIntermediateDirectories: true) - profileHome = confDir.appendingPathComponent("swiftly.fish", isDirectory: false) + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = FilePath(xdgConfigHome) { + let confDir = xdgConfigURL / "fish/conf.d" + try await mkdir(atPath: confDir, parents: true) + profileHome = confDir / "swiftly.fish" } else { - let confDir = userHome.appendingPathComponent( - ".config/fish/conf.d", isDirectory: true - ) - try FileManager.default.createDirectory( - at: confDir, withIntermediateDirectories: true - ) - profileHome = confDir.appendingPathComponent("swiftly.fish", isDirectory: false) + let confDir = userHome / ".config/fish/conf.d" + try await mkdir(atPath: confDir, parents: true) + profileHome = confDir / "swiftly.fish" } } else { - profileHome = userHome.appendingPathComponent(".profile", isDirectory: false) + profileHome = userHome / ".profile" } var addEnvToProfile = false do { - if !FileManager.default.fileExists(atPath: profileHome.path) { + if !(try await fileExists(atPath: profileHome)) { addEnvToProfile = true - } else if case let profileContents = try String(contentsOf: profileHome), !profileContents.contains(sourceLine) { + } else if case let profileContents = try String(contentsOf: profileHome, encoding: .utf8), !profileContents.contains(sourceLine) { addEnvToProfile = true } } catch { diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index ac056362..7a6fdc75 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -2,6 +2,7 @@ import _StringProcessing import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage @preconcurrency import TSCBasic import TSCUtility @@ -81,9 +82,9 @@ struct Install: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) - var config = try Config.load(ctx) + var config = try await Config.load(ctx) var selector: ToolchainSelector @@ -155,7 +156,7 @@ struct Install: SwiftlyCommand { } try Data(postInstallScript.utf8).write( - to: URL(fileURLWithPath: postInstallFile), options: .atomic + to: FilePath(postInstallFile), options: .atomic ) } } @@ -184,173 +185,170 @@ struct Install: SwiftlyCommand { await ctx.print("Installing \(version)") - let tmpFile = Swiftly.currentPlatform.getTempFilePath() - FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - var platformString = config.platform.name - var platformFullString = config.platform.nameFull + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + return try await withTemporary(files: tmpFile) { + var platformString = config.platform.name + var platformFullString = config.platform.nameFull #if !os(macOS) && arch(arm64) - platformString += "-aarch64" - platformFullString += "-aarch64" + platformString += "-aarch64" + platformFullString += "-aarch64" #endif - let category: String - switch version { - case let .stable(stableVersion): - // Building URL path that looks like: - // swift-5.6.2-release/ubuntu2004/swift-5.6.2-RELEASE/swift-5.6.2-RELEASE-ubuntu20.04.tar.gz - var versionString = "\(stableVersion.major).\(stableVersion.minor)" - if stableVersion.patch != 0 { - versionString += ".\(stableVersion.patch)" - } + let category: String + switch version { + case let .stable(stableVersion): + // Building URL path that looks like: + // swift-5.6.2-release/ubuntu2004/swift-5.6.2-RELEASE/swift-5.6.2-RELEASE-ubuntu20.04.tar.gz + var versionString = "\(stableVersion.major).\(stableVersion.minor)" + if stableVersion.patch != 0 { + versionString += ".\(stableVersion.patch)" + } - category = "swift-\(versionString)-release" - case let .snapshot(release): - switch release.branch { - case let .release(major, minor): - category = "swift-\(major).\(minor)-branch" - case .main: - category = "development" + category = "swift-\(versionString)-release" + case let .snapshot(release): + switch release.branch { + case let .release(major, minor): + category = "swift-\(major).\(minor)-branch" + case .main: + category = "development" + } } - } - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading \(version)" - ) + let animation = PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading \(version)" + ) - var lastUpdate = Date() + var lastUpdate = Date() - let toolchainFile = ToolchainFile( - category: category, platform: platformString, version: version.identifier, - file: - "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" - ) - - do { - try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download( - to: tmpFile, - reportProgress: { progress in - let now = Date() + let toolchainFile = ToolchainFile( + category: category, platform: platformString, version: version.identifier, + file: + "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" + ) - guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes - else { - return + do { + try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download( + to: tmpFile, + reportProgress: { progress in + let now = Date() + + guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes + else { + return + } + + let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) + let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + + lastUpdate = Date() + + animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) } + ) + } catch let notFound as DownloadNotFoundError { + throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") + } catch { + animation.complete(success: false) + throw error + } + animation.complete(success: true) + + if verifySignature { + try await Swiftly.currentPlatform.verifyToolchainSignature( + ctx, + toolchainFile: toolchainFile, + archive: tmpFile, + verbose: verbose + ) + } + + try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose) - let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) - let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + var pathChanged = false - lastUpdate = Date() + // Create proxies if we have a location where we can point them + if let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) { + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) + let swiftlyBinDirContents = + (try? await ls(atPath: swiftlyBinDir)) ?? [String]() + let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) + let toolchainBinDirContents = try await ls(atPath: toolchainBinDir) - animation.update( - step: progress.receivedBytes, - total: progress.totalBytes!, - text: - "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" - ) + var existingProxies: [String] = [] + + for bin in swiftlyBinDirContents { + do { + let linkTarget = try await readlink(atPath: swiftlyBinDir / bin) + if linkTarget == proxyTo { + existingProxies.append(bin) + } + } catch { continue } } - ) - } catch let notFound as DownloadNotFoundError { - throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") - } catch { - animation.complete(success: false) - throw error - } - animation.complete(success: true) - - if verifySignature { - try await Swiftly.currentPlatform.verifyToolchainSignature( - ctx, - toolchainFile: toolchainFile, - archive: tmpFile, - verbose: verbose - ) - } - try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose) - - var pathChanged = false - - // Create proxies if we have a location where we can point them - if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) { - // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. - let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let swiftlyBinDirContents = - (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() - let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) - let toolchainBinDirContents = try FileManager.default.contentsOfDirectory( - atPath: toolchainBinDir.path) - - let existingProxies = swiftlyBinDirContents.filter { bin in - do { - let linkTarget = try FileManager.default.destinationOfSymbolicLink( - atPath: swiftlyBinDir.appendingPathComponent(bin).path) - return linkTarget == proxyTo - } catch { return false } - } + let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( + swiftlyBinDirContents) + if !overwrite.isEmpty && !assumeYes { + await ctx.print("The following existing executables will be overwritten:") - let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( - swiftlyBinDirContents) - if !overwrite.isEmpty && !assumeYes { - await ctx.print("The following existing executables will be overwritten:") + for executable in overwrite { + await ctx.print(" \(swiftlyBinDir / executable)") + } - for executable in overwrite { - await ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)") + guard await ctx.promptForConfirmation(defaultBehavior: false) else { + throw SwiftlyError(message: "Toolchain installation has been cancelled") + } } - guard await ctx.promptForConfirmation(defaultBehavior: false) else { - throw SwiftlyError(message: "Toolchain installation has been cancelled") + if verbose { + await ctx.print("Setting up toolchain proxies...") } - } - if verbose { - await ctx.print("Setting up toolchain proxies...") - } + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( + overwrite) - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( - overwrite) + for p in proxiesToCreate { + let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p - for p in proxiesToCreate { - let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p) + if try await fileExists(atPath: proxy) { + try await remove(atPath: proxy) + } - if proxy.fileExists() { - try FileManager.default.removeItem(at: proxy) - } + try await symlink(atPath: proxy, linkPath: proxyTo) - try FileManager.default.createSymbolicLink( - atPath: proxy.path, - withDestinationPath: proxyTo - ) - - pathChanged = true + pathChanged = true + } } - } - config.installedToolchains.insert(version) + config.installedToolchains.insert(version) - try config.save(ctx) + try config.save(ctx) - // If this is the first installed toolchain, mark it as in-use regardless of whether the - // --use argument was provided. - if useInstalledToolchain { - try await Use.execute(ctx, version, globalDefault: false, &config) - } + // If this is the first installed toolchain, mark it as in-use regardless of whether the + // --use argument was provided. + if useInstalledToolchain { + try await Use.execute(ctx, version, globalDefault: false, &config) + } - // We always update the global default toolchain if there is none set. This could - // be the only toolchain that is installed, which makes it the only choice. - if config.inUse == nil { - config.inUse = version - try config.save(ctx) - await ctx.print("The global default toolchain has been set to `\(version)`") - } + // We always update the global default toolchain if there is none set. This could + // be the only toolchain that is installed, which makes it the only choice. + if config.inUse == nil { + config.inUse = version + try config.save(ctx) + await ctx.print("The global default toolchain has been set to `\(version)`") + } - await ctx.print("\(version) installed successfully!") - return (postInstallScript, pathChanged) + await ctx.print("\(version) installed successfully!") + return (postInstallScript, pathChanged) + } } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index a3fd54ed..82563f06 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -38,12 +38,12 @@ struct List: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 } let (inUse, _) = try await selectToolchain(ctx, config: &config) diff --git a/Sources/Swiftly/ListAvailable.swift b/Sources/Swiftly/ListAvailable.swift index e8ba1f2f..c2d9bccf 100644 --- a/Sources/Swiftly/ListAvailable.swift +++ b/Sources/Swiftly/ListAvailable.swift @@ -44,12 +44,12 @@ struct ListAvailable: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - let config = try Config.load(ctx) + let config = try await Config.load(ctx) let tc: [ToolchainVersion] diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift index 37b710c6..cf5e409e 100644 --- a/Sources/Swiftly/Proxy.swift +++ b/Sources/Swiftly/Proxy.swift @@ -15,7 +15,12 @@ public enum Proxy { guard binName != "swiftly" else { // Treat this as a swiftly invocation, but first check that we are installed, bootstrapping // the installation process if we aren't. - let configResult = Result { try Config.load(ctx) } + let configResult: Result + do { + configResult = Result.success(try await Config.load(ctx)) + } catch { + configResult = Result.failure(error) + } switch configResult { case .success: @@ -45,7 +50,7 @@ public enum Proxy { } } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let (toolchain, result) = try await selectToolchain(ctx, config: &config) diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift index 3bbc1012..be0e1aaf 100644 --- a/Sources/Swiftly/Run.swift +++ b/Sources/Swiftly/Run.swift @@ -58,14 +58,14 @@ struct Run: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) // Handle the specific case where help is requested of the run subcommand if command == ["--help"] { throw CleanExit.helpRequest(self) } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let (command, selector) = try Self.extractProxyArguments(command: self.command) diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index e0d8d7c2..4a174965 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -20,10 +20,10 @@ struct SelfUpdate: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) - let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent("swiftly") - guard FileManager.default.fileExists(atPath: swiftlyBin.path) else { + let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) / "swiftly" + guard try await fileExists(atPath: swiftlyBin) else { throw SwiftlyError( message: "Self update doesn't work when swiftly has been installed externally. Please keep it updated from the source where you installed it in the first place." @@ -75,43 +75,41 @@ struct SelfUpdate: SwiftlyCommand { await ctx.print("A new version is available: \(version)") - let tmpFile = Swiftly.currentPlatform.getTempFilePath() - FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading swiftly \(version)" - ) - do { - try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download( - to: tmpFile, - reportProgress: { progress in - let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) - let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) - - animation.update( - step: progress.receivedBytes, - total: progress.totalBytes!, - text: - "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" - ) - } + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + return try await withTemporary(files: tmpFile) { + let animation = PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading swiftly \(version)" ) - } catch { - animation.complete(success: false) - throw error - } - animation.complete(success: true) + do { + try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download( + to: tmpFile, + reportProgress: { progress in + let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) + let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + + animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) + } + ) + } catch { + animation.complete(success: false) + throw error + } + animation.complete(success: true) - try await Swiftly.currentPlatform.verifySwiftlySignature( - ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose - ) - try await Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile) + try await Swiftly.currentPlatform.verifySwiftlySignature( + ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose + ) + try await Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile) - await ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") - return version + await ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") + return version + } } } diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index dc4ecb82..27047883 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -6,6 +6,7 @@ import LinuxPlatform import MacOSPlatform #endif import SwiftlyCore +import SystemPackage public struct GlobalOptions: ParsableArguments { @Flag(name: [.customShort("y"), .long], help: "Disable confirmation prompts by assuming 'yes'") @@ -42,7 +43,7 @@ public struct Swiftly: SwiftlyCommand { /// The list of directories that swiftly needs to exist in order to execute. /// If they do not exist when a swiftly command is invoked, they will be created. - public static func requiredDirectories(_ ctx: SwiftlyCoreContext) -> [URL] { + public static func requiredDirectories(_ ctx: SwiftlyCoreContext) -> [FilePath] { [ Swiftly.currentPlatform.swiftlyHomeDir(ctx), Swiftly.currentPlatform.swiftlyBinDir(ctx), @@ -66,8 +67,8 @@ public protocol SwiftlyCommand: AsyncParsableCommand { } extension Data { - func append(to file: URL) throws { - if let fileHandle = FileHandle(forWritingAtPath: file.path) { + func append(to file: FilePath) throws { + if let fileHandle = FileHandle(forWritingAtPath: file.string) { defer { fileHandle.closeFile() } @@ -80,19 +81,19 @@ extension Data { } extension SwiftlyCommand { - public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) throws { + public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) async throws { for requiredDir in Swiftly.requiredDirectories(ctx) { - guard requiredDir.fileExists() else { + guard try await fileExists(atPath: requiredDir) else { do { - try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + try await mkdir(atPath: requiredDir, parents: true) } catch { - throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir.path)\": \(error)") + throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir)\": \(error)") } continue } } // Verify that the configuration exists and can be loaded - _ = try Config.load(ctx) + _ = try await Config.load(ctx) } } diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index e73dc213..5a1c8332 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -48,8 +48,8 @@ struct Uninstall: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - let startingConfig = try Config.load(ctx) + try await validateSwiftly(ctx) + let startingConfig = try await Config.load(ctx) let toolchains: [ToolchainVersion] if self.toolchain == "all" { @@ -89,7 +89,7 @@ struct Uninstall: SwiftlyCommand { await ctx.print() for toolchain in toolchains { - var config = try Config.load(ctx) + var config = try await Config.load(ctx) // If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain. if toolchain == config.inUse { diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index 60247555..ff097f74 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Update: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -82,8 +83,8 @@ struct Update: SwiftlyCommand { } public mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - var config = try Config.load(ctx) + try await validateSwiftly(ctx) + var config = try await Config.load(ctx) guard let parameters = try await self.resolveUpdateParameters(ctx, &config) else { if let toolchain = self.toolchain { @@ -137,7 +138,7 @@ struct Update: SwiftlyCommand { """) } - try Data(postInstallScript.utf8).write(to: URL(fileURLWithPath: postInstallFile), options: .atomic) + try Data(postInstallScript.utf8).write(to: FilePath(postInstallFile), options: .atomic) } if pathChanged { diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index c5f146dc..5cbba782 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Use: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -59,8 +60,8 @@ struct Use: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - var config = try Config.load(ctx) + try await validateSwiftly(ctx) + var config = try await Config.load(ctx) // This is the bare use command where we print the selected toolchain version (or the path to it) guard let toolchain = self.toolchain else { @@ -78,7 +79,7 @@ struct Use: SwiftlyCommand { if self.printLocation { // Print the toolchain location and exit - await ctx.print("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion).path)") + await ctx.print("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))") return } @@ -86,7 +87,7 @@ struct Use: SwiftlyCommand { switch result { case let .swiftVersionFile(versionFile, _, _): - message += " (\(versionFile.path))" + message += " (\(versionFile))" case .globalDefault: message += " (default)" } @@ -118,10 +119,10 @@ struct Use: SwiftlyCommand { if case let .swiftVersionFile(versionFile, _, _) = result { // We don't care in this case if there were any problems with the swift version files, just overwrite it with the new value - try toolchain.name.write(to: versionFile, atomically: true, encoding: .utf8) + try toolchain.name.write(to: versionFile, atomically: true) - message = "The file `\(versionFile.path)` has been set to `\(toolchain)`" - } else if let newVersionFile = findNewVersionFile(ctx), !globalDefault { + message = "The file `\(versionFile)` has been set to `\(toolchain)`" + } else if let newVersionFile = try await findNewVersionFile(ctx), !globalDefault { if !assumeYes { await ctx.print("A new file `\(newVersionFile)` will be created to set the new in-use toolchain for this project. Alternatively, you can set your default globally with the `--global-default` flag. Proceed with creating this file?") @@ -131,9 +132,9 @@ struct Use: SwiftlyCommand { } } - try toolchain.name.write(to: newVersionFile, atomically: true, encoding: .utf8) + try toolchain.name.write(to: newVersionFile, atomically: true) - message = "The file `\(newVersionFile.path)` has been set to `\(toolchain)`" + message = "The file `\(newVersionFile)` has been set to `\(toolchain)`" } else { config.inUse = toolchain try config.save(ctx) @@ -147,21 +148,21 @@ struct Use: SwiftlyCommand { await ctx.print(message) } - static func findNewVersionFile(_ ctx: SwiftlyCoreContext) -> URL? { + static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { var cwd = ctx.currentDirectory - while cwd.path != "" && cwd.path != "/" { - guard FileManager.default.fileExists(atPath: cwd.path) else { + while !cwd.isEmpty && !cwd.removingRoot().isEmpty { + guard try await fileExists(atPath: cwd) else { break } - let gitDir = cwd.appendingPathComponent(".git") + let gitDir = cwd / ".git" - if FileManager.default.fileExists(atPath: gitDir.path) { - return cwd.appendingPathComponent(".swift-version") + if try await fileExists(atPath: gitDir) { + return cwd / ".swift-version" } - cwd = cwd.deletingLastPathComponent() + cwd = cwd.removingLastComponent() } return nil @@ -170,7 +171,7 @@ struct Use: SwiftlyCommand { public enum ToolchainSelectionResult: Sendable { case globalDefault - case swiftVersionFile(URL, ToolchainSelector?, Error?) + case swiftVersionFile(FilePath, ToolchainSelector?, Error?) } /// Returns the currently selected swift toolchain, if any, with details of the selection. @@ -197,15 +198,15 @@ public func selectToolchain(_ ctx: SwiftlyCoreContext, config: inout Config, glo if !globalDefault { var cwd = ctx.currentDirectory - while cwd.path != "" && cwd.path != "/" { - guard FileManager.default.fileExists(atPath: cwd.path) else { + while !cwd.isEmpty && !cwd.removingRoot().isEmpty { + guard try await fileExists(atPath: cwd) else { break } - let svFile = cwd.appendingPathComponent(".swift-version") + let svFile = cwd / ".swift-version" - if FileManager.default.fileExists(atPath: svFile.path) { - let contents = try? String(contentsOf: svFile, encoding: .utf8) + if try await fileExists(atPath: svFile) { + let contents = try? String(contentsOf: svFile) guard let contents else { return (nil, .swiftVersionFile(svFile, nil, SwiftlyError(message: "The swift version file could not be read: \(svFile)"))) @@ -228,13 +229,13 @@ public func selectToolchain(_ ctx: SwiftlyCoreContext, config: inout Config, glo } guard let selectedToolchain = config.listInstalledToolchains(selector: selector).max() else { - return (nil, .swiftVersionFile(svFile, selector, SwiftlyError(message: "The swift version file `\(svFile.path)` uses toolchain version \(selector), but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`."))) + return (nil, .swiftVersionFile(svFile, selector, SwiftlyError(message: "The swift version file `\(svFile)` uses toolchain version \(selector), but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`."))) } return (selectedToolchain, .swiftVersionFile(svFile, selector, nil)) } - cwd = cwd.deletingLastPathComponent() + cwd = cwd.removingLastComponent() } } diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift new file mode 100644 index 00000000..1237da07 --- /dev/null +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -0,0 +1,179 @@ +import Foundation +import SystemPackage + +public var cwd: FilePath { + FileManager.default.currentDir +} + +public var homeDir: FilePath { + FileManager.default.homeDir +} + +public var tmpDir: FilePath { + FileManager.default.temporaryDir +} + +public func fileExists(atPath: FilePath) async throws -> Bool { + FileManager.default.fileExists(atPath: atPath) +} + +public func remove(atPath: FilePath) async throws { + try FileManager.default.removeItem(atPath: atPath) +} + +public func move(atPath: FilePath, toPath: FilePath) async throws { + try FileManager.default.moveItem(atPath: atPath, toPath: toPath) +} + +public func copy(atPath: FilePath, toPath: FilePath) async throws { + try FileManager.default.copyItem(atPath: atPath, toPath: toPath) +} + +public func mkdir(atPath: FilePath, parents: Bool = false) async throws { + try FileManager.default.createDir(atPath: atPath, withIntermediateDirectories: parents) +} + +public func cat(atPath: FilePath) async throws -> Data { + guard let data = FileManager.default.contents(atPath: atPath) else { + throw SwiftlyError(message: "File at path \(atPath) could not be read") + } + + return data +} + +public func mktemp(ext: String? = nil) -> FilePath { + FileManager.default.temporaryDir.appending("swiftly-\(UUID())\(ext ?? "")") +} + +public func withTemporary(files: FilePath..., f: () async throws -> T) async throws -> T { + try await withTemporary(files: files, f: f) +} + +public func withTemporary(files: [FilePath], f: () async throws -> T) async throws -> T { + do { + let t: T = try await f() + + for f in files { + try? await remove(atPath: f) + } + + return t + } catch { + // Sort the list in case there are temporary files nested within other temporary files + for f in files.map(\.string).sorted() { + try? await remove(atPath: FilePath(f)) + } + + throw error + } +} + +public func create(file: FilePath, contents: Data?, mode: Int = 0) async throws { + if mode != 0 { + _ = FileManager.default.createFile(atPath: file.string, contents: contents, attributes: [.posixPermissions: mode]) + } else { + _ = FileManager.default.createFile(atPath: file.string, contents: contents) + } +} + +public func ls(atPath: FilePath) async throws -> [String] { + try FileManager.default.contentsOfDir(atPath: atPath) +} + +public func readlink(atPath: FilePath) async throws -> FilePath { + try FileManager.default.destinationOfSymbolicLink(atPath: atPath) +} + +public func symlink(atPath: FilePath, linkPath: FilePath) async throws { + try FileManager.default.createSymbolicLink(atPath: atPath, withDestinationPath: linkPath) +} + +public func chmod(atPath: FilePath, mode: Int) async throws { + try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: atPath.string) +} + +extension FileManager { + public var currentDir: FilePath { + FilePath(Self.default.currentDirectoryPath) + } + + public var homeDir: FilePath { + FilePath(Self.default.homeDirectoryForCurrentUser.path) + } + + public func fileExists(atPath path: FilePath) -> Bool { + Self.default.fileExists(atPath: path.string, isDirectory: nil) + } + + public func removeItem(atPath path: FilePath) throws { + try Self.default.removeItem(atPath: path.string) + } + + public func moveItem(atPath: FilePath, toPath: FilePath) throws { + try Self.default.moveItem(atPath: atPath.string, toPath: toPath.string) + } + + public func copyItem(atPath: FilePath, toPath: FilePath) throws { + try Self.default.copyItem(atPath: atPath.string, toPath: toPath.string) + } + + public func deleteIfExists(atPath path: FilePath) throws { + do { + try Self.default.removeItem(atPath: path.string) + } catch let error as NSError { + guard error.domain == NSCocoaErrorDomain && error.code == CocoaError.fileNoSuchFile.rawValue else { + throw error + } + } + } + + public func createDir(atPath: FilePath, withIntermediateDirectories: Bool) throws { + try Self.default.createDirectory(atPath: atPath.string, withIntermediateDirectories: withIntermediateDirectories) + } + + public func contents(atPath: FilePath) -> Data? { + Self.default.contents(atPath: atPath.string) + } + + public var temporaryDir: FilePath { + FilePath(Self.default.temporaryDirectory.path) + } + + public func contentsOfDir(atPath: FilePath) throws -> [String] { + try Self.default.contentsOfDirectory(atPath: atPath.string) + } + + public func destinationOfSymbolicLink(atPath: FilePath) throws -> FilePath { + FilePath(try Self.default.destinationOfSymbolicLink(atPath: atPath.string)) + } + + public func createSymbolicLink(atPath: FilePath, withDestinationPath: FilePath) throws { + try Self.default.createSymbolicLink(atPath: atPath.string, withDestinationPath: withDestinationPath.string) + } +} + +extension Data { + public func write(to path: FilePath, options: Data.WritingOptions = []) throws { + try self.write(to: URL(fileURLWithPath: path.string), options: options) + } + + public init(contentsOf path: FilePath) throws { + try self.init(contentsOf: URL(fileURLWithPath: path.string)) + } +} + +extension String { + public func write(to path: FilePath, atomically: Bool, encoding enc: String.Encoding = .utf8) throws { + try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) + } + + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { + try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) + } +} + +extension FilePath { + public static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index 308e1700..b3dc3806 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -9,6 +9,7 @@ import OpenAPIAsyncHTTPClient import OpenAPIRuntime import SwiftlyDownloadAPI import SwiftlyWebsiteAPI +import SystemPackage extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { public var swiftlyVersion: SwiftlyVersion { @@ -578,10 +579,11 @@ public struct SwiftlyHTTPClient: Sendable { } extension OpenAPIRuntime.HTTPBody { - public func download(to destination: URL, reportProgress: ((DownloadProgress) -> Void)? = nil) + public func download(to destination: FilePath, reportProgress: ((DownloadProgress) -> Void)? = nil) async throws { - let fileHandle = try FileHandle(forWritingTo: destination) + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination.string)) + defer { try? fileHandle.close() } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 2d06ee01..1d0f61a8 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,4 +1,5 @@ import Foundation +import SystemPackage public struct PlatformDefinition: Codable, Equatable, Sendable { /// The name of the platform as it is used in the Swift download URLs. @@ -56,7 +57,7 @@ public struct RunProgramError: Swift.Error { public protocol Platform: Sendable { /// The platform-specific default location on disk for swiftly's home /// directory. - var defaultSwiftlyHomeDirectory: URL { 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. @@ -64,23 +65,23 @@ 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) -> URL + func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath /// The "toolchains" subdirectory that contains the Swift toolchains managed by swiftly. - func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL + 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". var toolchainFileExtension: String { get } - /// Installs a toolchain from a file on disk pointed to by the given URL. + /// 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: URL, 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: URL) 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. @@ -89,12 +90,8 @@ public protocol Platform: Sendable { /// Get the name of the swiftly release binary. func getExecutableName() -> String - /// Get a path pointing to a unique, temporary file. - /// This does not need to actually create the file. - func getTempFilePath() -> URL - /// Verifies that the system meets the requirements for swiftly to be installed on the system. - func verifySwiftlySystemPrerequisites() throws + func verifySwiftlySystemPrerequisites() async throws /// Verifies that the system meets the requirements needed to install a swift toolchain of the provided version. /// @@ -114,14 +111,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: URL, 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: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool ) async throws /// Detect the platform definition for this platform. @@ -132,10 +129,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) -> URL + func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath /// Find the location of the toolchain binaries. - func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath } extension Platform { @@ -152,24 +149,24 @@ extension Platform { /// -- config.json /// ``` /// - public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> URL { + public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { ctx.mockedHomeDir - ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { URL(fileURLWithPath: $0) } - ?? self.defaultSwiftlyHomeDirectory + ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { FilePath($0) } + ?? self.defaultSwiftlyHomeDir } - /// The URL of the configuration file in swiftly's home directory. - public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> URL { - self.swiftlyHomeDir(ctx).appendingPathComponent("config.json") + /// The path of the configuration file in swiftly's home directory. + public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> FilePath { + self.swiftlyHomeDir(ctx) / "config.json" } #if os(macOS) || os(Linux) - func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) throws -> [ + func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> [ String: String ] { - let tcPath = self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") - guard tcPath.fileExists() else { + let tcPath = self.findToolchainLocation(ctx, toolchain) / "usr/bin" + guard try await fileExists(atPath: tcPath) else { throw SwiftlyError( message: "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." @@ -179,8 +176,8 @@ extension Platform { // The toolchain goes to the beginning of the PATH var newPath = newEnv["PATH"] ?? "" - if !newPath.hasPrefix(tcPath.path + ":") { - newPath = "\(tcPath.path):\(newPath)" + if !newPath.hasPrefix(tcPath.string + ":") { + newPath = "\(tcPath):\(newPath)" } newEnv["PATH"] = newPath @@ -196,7 +193,7 @@ extension Platform { _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String], _ env: [String: String] = [:] ) async throws { - var newEnv = try self.proxyEnv(ctx, toolchain) + var newEnv = try await self.proxyEnv(ctx, toolchain) for (key, value) in env { newEnv[key] = value } @@ -212,7 +209,7 @@ extension Platform { _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String] ) async throws -> String? { - try await self.runProgramOutput(command, arguments, env: self.proxyEnv(ctx, toolchain)) + try await self.runProgramOutput(command, arguments, env: await self.proxyEnv(ctx, toolchain)) } /// Run a program. @@ -330,18 +327,23 @@ extension Platform { public func installSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws { // First, let's find out where we are. let cmd = CommandLine.arguments[0] - let cmdAbsolute = - if cmd.hasPrefix("/") - { - cmd + + var cmdAbsolute: FilePath? + + if cmd.hasPrefix("/") { + cmdAbsolute = FilePath(cmd) } else { - ([FileManager.default.currentDirectoryPath] - + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + let pathEntries = ([cwd.string] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + FilePath($0) / cmd + } + + for pathEntry in pathEntries { + if try await fileExists(atPath: pathEntry) { + cmdAbsolute = pathEntry + break + } + } } // We couldn't find ourselves in the usual places. Assume that no installation is necessary @@ -351,12 +353,11 @@ extension Platform { } // Proceed to installation only if we're in the user home directory, or a non-system location. - let userHome = FileManager.default.homeDirectoryForCurrentUser - guard - cmdAbsolute.hasPrefix(userHome.path + "/") - || (!cmdAbsolute.hasPrefix("/usr/") && !cmdAbsolute.hasPrefix("/opt/") - && !cmdAbsolute.hasPrefix("/bin/")) - else { + let userHome = homeDir + + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + + guard cmdAbsolute.starts(with: userHome) || systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first == nil else { return } @@ -367,23 +368,22 @@ extension Platform { // We're already running from where we would be installing ourselves. guard - case let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( - "swiftly", isDirectory: false - ).path, cmdAbsolute != swiftlyHomeBin + case let swiftlyHomeBin = self.swiftlyBinDir(ctx) / "swiftly", + cmdAbsolute != swiftlyHomeBin else { return } await ctx.print("Installing swiftly in \(swiftlyHomeBin)...") - if FileManager.default.fileExists(atPath: swiftlyHomeBin) { - try FileManager.default.removeItem(atPath: swiftlyHomeBin) + if try await fileExists(atPath: swiftlyHomeBin) { + try await remove(atPath: swiftlyHomeBin) } do { - try FileManager.default.moveItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) + try await move(atPath: cmdAbsolute, toPath: swiftlyHomeBin) } catch { - try FileManager.default.copyItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) + try await copy(atPath: cmdAbsolute, toPath: swiftlyHomeBin) await ctx.print( "Swiftly has been copied into the installation directory. You can remove '\(cmdAbsolute)'. It is no longer needed." ) @@ -391,54 +391,55 @@ extension Platform { } // Find the location where swiftly should be executed. - public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) throws -> String? { - let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( - "swiftly", isDirectory: false - ).path + 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] - let cmdAbsolute = - if cmd.hasPrefix("/") - { - cmd + var cmdAbsolute: FilePath? + if cmd.hasPrefix("/") { + cmdAbsolute = FilePath(cmd) } else { - ([FileManager.default.currentDirectoryPath] - + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + let pathEntries = ([cwd.string] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + FilePath($0) / cmd + } + + for pathEntry in pathEntries { + if try await fileExists(atPath: pathEntry) { + cmdAbsolute = pathEntry + break + } + } } // We couldn't find ourselves in the usual places, so if we're not going to be installing // swiftly then we can assume that we are running from the final location. - if cmdAbsolute == nil && FileManager.default.fileExists(atPath: swiftlyHomeBin) { + let homeBinExists = try await fileExists(atPath: swiftlyHomeBin) + if cmdAbsolute == nil && homeBinExists { return swiftlyHomeBin } + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + // If we are system managed then we know where swiftly should be. - let userHome = FileManager.default.homeDirectoryForCurrentUser - if let cmdAbsolute, - !cmdAbsolute.hasPrefix(userHome.path + "/") - && (cmdAbsolute.hasPrefix("/usr/") || cmdAbsolute.hasPrefix("/opt/") - || cmdAbsolute.hasPrefix("/bin/")) - { + let userHome = homeDir + + if let cmdAbsolute, !cmdAbsolute.starts(with: userHome) && systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first != nil { return cmdAbsolute } // If we're running inside an xctest then we don't have a location for this swiftly. - guard let cmdAbsolute, !cmdAbsolute.hasSuffix("xctest") else { + guard let cmdAbsolute, !cmdAbsolute.string.hasSuffix("xctest") else { return nil } - return FileManager.default.fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil + return try await fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil } - public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") + self.findToolchainLocation(ctx, toolchain) / "usr/bin" } #endif diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 4cdd9f04..eabe22cc 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyWebsiteAPI +import SystemPackage public let version = SwiftlyVersion(major: 1, minor: 1, patch: 0, suffix: "dev") @@ -18,11 +19,11 @@ public protocol InputProvider: Actor { public struct SwiftlyCoreContext: Sendable { /// A separate home directory to use for testing purposes. This overrides swiftly's default /// home directory location logic. - public var mockedHomeDir: URL? + public var mockedHomeDir: FilePath? /// A separate current working directory to use for testing purposes. This overrides /// swiftly's default current working directory logic. - public var currentDirectory: URL + public var currentDirectory: FilePath /// A chosen shell for the current user as a typical path to the shell's binary /// location (e.g. /bin/sh). This overrides swiftly's default shell detection mechanisms @@ -41,7 +42,12 @@ public struct SwiftlyCoreContext: Sendable { public init() { self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - self.currentDirectory = URL.currentDirectory() + self.currentDirectory = cwd + } + + public init(httpClient: SwiftlyHTTPClient) { + self.httpClient = httpClient + self.currentDirectory = cwd } /// Pass the provided string to the set output handler if any. diff --git a/Sources/SwiftlyCore/Utils.swift b/Sources/SwiftlyCore/Utils.swift deleted file mode 100644 index 285fe90c..00000000 --- a/Sources/SwiftlyCore/Utils.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -extension URL { - public func fileExists() -> Bool { - FileManager.default.fileExists(atPath: self.path, isDirectory: nil) - } - - public func deleteIfExists() throws { - do { - try FileManager.default.removeItem(at: self) - } catch let error as NSError { - guard error.domain == NSCocoaErrorDomain && error.code == CocoaError.fileNoSuchFile.rawValue else { - throw error - } - } - } -} diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 1feec811..af04532a 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -3,11 +3,15 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore import SwiftlyWebsiteAPI +import SystemPackage import Testing @Suite(.serialized) struct HTTPClientTests { @Test func getSwiftOrgGPGKeys() async throws { - try await withTemporaryFile { tmpFile in + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + + try await withTemporary(files: tmpFile) { let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) try await retry { @@ -15,61 +19,65 @@ import Testing } try await withGpg { runGpg in - try runGpg(["--import", tmpFile.path]) + try runGpg(["--import", "\(tmpFile)"]) } } } @Test func getSwiftToolchain() async throws { - try await withTemporaryFile { tmpFile in - try await withTemporaryFile { tmpFileSignature in - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - - let toolchainFile = ToolchainFile(category: "swift-6.0-release", platform: "ubuntu2404", version: "swift-6.0-RELEASE", file: "swift-6.0-RELEASE-ubuntu24.04.tar.gz") + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + let tmpFileSignature = mktemp(ext: ".sig") + try await create(file: tmpFileSignature, contents: nil) + let keysFile = mktemp(ext: ".asc") + try await create(file: keysFile, contents: nil) + + try await withTemporary(files: tmpFile, tmpFileSignature, keysFile) { + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - try await retry { - try await httpClient.getSwiftToolchainFile(toolchainFile).download(to: tmpFile) - } + let toolchainFile = ToolchainFile(category: "swift-6.0-release", platform: "ubuntu2404", version: "swift-6.0-RELEASE", file: "swift-6.0-RELEASE-ubuntu24.04.tar.gz") - try await retry { - try await httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: tmpFileSignature) - } + try await retry { + try await httpClient.getSwiftToolchainFile(toolchainFile).download(to: tmpFile) + } - try await withGpg { runGpg in - try await withTemporaryFile { keysFile in - try await httpClient.getGpgKeys().download(to: keysFile) - try runGpg(["--import", keysFile.path]) - } + try await retry { + try await httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: tmpFileSignature) + } - try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) - } + try await withGpg { runGpg in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", "\(keysFile)"]) + try runGpg(["--verify", "\(tmpFileSignature)", "\(tmpFile)"]) } } } @Test func getSwiftlyRelease() async throws { - try await withTemporaryFile { tmpFile in - try await withTemporaryFile { tmpFileSignature in - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - - let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + let tmpFileSignature = mktemp(ext: ".sig") + try await create(file: tmpFileSignature, contents: nil) + let keysFile = mktemp(ext: ".asc") + try await create(file: keysFile, contents: nil) + + try await withTemporary(files: tmpFile, tmpFileSignature, keysFile) { + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - try await retry { - try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) - } + let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) - try await retry { - try await httpClient.getSwiftlyReleaseSignature(url: swiftlyURL.appendingPathExtension("sig")).download(to: tmpFileSignature) - } + try await retry { + try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) + } - try await withGpg { runGpg in - try await withTemporaryFile { keysFile in - try await httpClient.getGpgKeys().download(to: keysFile) - try runGpg(["--import", keysFile.path]) - } + try await retry { + try await httpClient.getSwiftlyReleaseSignature(url: swiftlyURL.appendingPathExtension("sig")).download(to: tmpFileSignature) + } - try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) - } + try await withGpg { runGpg in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", "\(keysFile)"]) + try runGpg(["--verify", "\(tmpFileSignature)", "\(tmpFile)"]) } } } @@ -90,6 +98,10 @@ import Testing [PlatformDefinition.macOS, .ubuntu2404, .ubuntu2204, .rhel9, .fedora39, .amazonlinux2, .debian12], [SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664, .aarch64] ) func getToolchainMetdataFromSwiftOrg(_ platform: PlatformDefinition, _ arch: SwiftlyWebsiteAPI.Components.Schemas.Architecture) async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) let branches: [ToolchainVersion.Snapshot.Branch] = [ @@ -113,30 +125,19 @@ import Testing } } -private func withTemporaryFile(_ body: (URL) async throws -> T) async rethrows -> T { - let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - _ = FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - return try await body(tmpFile) -} - private func withGpg(_ body: (([String]) throws -> Void) async throws -> Void) async throws { #if os(Linux) // With linux, we can ask gpg to try an import to see if the file is valid // in a sandbox home directory to avoid contaminating the system - let gpgHome = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - try FileManager.default.createDirectory(atPath: gpgHome.path, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: gpgHome) - } + let gpgHome = mktemp() + try await mkdir(atPath: gpgHome, parents: true) + try await withTemporary(files: gpgHome) { + func runGpg(arguments: [String]) throws { + try Swiftly.currentPlatform.runProgram(["gpg"] + arguments, quiet: false, env: ["GNUPGHOME": gpgHome.string]) + } - func runGpg(arguments: [String]) throws { - try Swiftly.currentPlatform.runProgram(["gpg"] + arguments, quiet: false, env: ["GNUPGHOME": gpgHome.path]) + try await body(runGpg) } - - try await body(runGpg) #endif } diff --git a/Tests/SwiftlyTests/InitTests.swift b/Tests/SwiftlyTests/InitTests.swift index e5fe510a..bbcdaba2 100644 --- a/Tests/SwiftlyTests/InitTests.swift +++ b/Tests/SwiftlyTests/InitTests.swift @@ -1,46 +1,47 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct InitTests { @Test(.testHome(), arguments: ["/bin/bash", "/bin/zsh", "/bin/fish"]) func initFresh(_ shell: String) async throws { // GIVEN: a fresh user account without swiftly installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) // AND: the user is using the bash shell var ctx = SwiftlyTests.ctx ctx.mockedShell = shell try await SwiftlyTests.$ctx.withValue(ctx) { - let envScript: URL? + let envScript: FilePath? if shell.hasSuffix("bash") || shell.hasSuffix("zsh") { - envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("env.sh") + envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh" } else if shell.hasSuffix("fish") { - envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("env.fish") + envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish" } else { envScript = nil } if let envScript { - #expect(!envScript.fileExists()) + #expect(!(try await fileExists(atPath: envScript))) } // WHEN: swiftly is invoked to init the user account and finish swiftly installation try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // THEN: it creates a valid configuration at the correct version - let config = try Config.load() + let config = try await Config.load() #expect(SwiftlyCore.version == config.version) // AND: it creates an environment script suited for the type of shell if let envScript { - #expect(envScript.fileExists()) + #expect(try await fileExists(atPath: envScript)) if let scriptContents = try? String(contentsOf: envScript) { #expect(scriptContents.contains("SWIFTLY_HOME_DIR")) #expect(scriptContents.contains("SWIFTLY_BIN_DIR")) - #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).path)) - #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).path)) + #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).string)) + #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) } } @@ -48,9 +49,9 @@ import Testing if let envScript { var foundSourceLine = false for p in [".profile", ".zprofile", ".bash_profile", ".bash_login", ".config/fish/conf.d/swiftly.fish"] { - let profile = SwiftlyTests.ctx.mockedHomeDir!.appendingPathComponent(p) - if profile.fileExists() { - if let profileContents = try? String(contentsOf: profile), profileContents.contains(envScript.path) { + let profile = SwiftlyTests.ctx.mockedHomeDir! / p + if try await fileExists(atPath: profile) { + if let profileContents = try? String(contentsOf: profile), profileContents.contains(envScript.string) { foundSourceLine = true break } @@ -63,41 +64,41 @@ import Testing @Test(.testHome()) func initOverwrite() async throws { // GIVEN: a user account with swiftly already installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // Add some customizations to files and directories - var config = try Config.load() + var config = try await Config.load() config.version = try SwiftlyVersion(parsing: "100.0.0") try config.save() - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt") + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt") // WHEN: swiftly is initialized with overwrite enabled try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install", "--overwrite"]) // THEN: everything is overwritten in initialization - config = try Config.load() + config = try await Config.load() #expect(SwiftlyCore.version == config.version) - #expect(!Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) - #expect(!Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) + #expect(!(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt"))) + #expect(!(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt"))) } @Test(.testHome()) func initTwice() async throws { // GIVEN: a user account with swiftly already installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // Add some customizations to files and directories - var config = try Config.load() + var config = try await Config.load() config.version = try SwiftlyVersion(parsing: "100.0.0") try config.save() - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt") + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt") // WHEN: swiftly init is invoked a second time var threw = false @@ -111,9 +112,9 @@ import Testing #expect(threw) // AND: files were left intact - config = try Config.load() + config = try await Config.load() #expect(try SwiftlyVersion(parsing: "100.0.0") == config.version) - #expect(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) - #expect(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) + #expect(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt")) + #expect(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt")) } } diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index ebee9c2b..8d0d911c 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -10,9 +10,9 @@ import Testing /// behavior, since determining which version is the latest is non-trivial and would require duplicating code /// from within swiftly itself. @Test(.testHomeMockedToolchain()) func installLatest() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "latest", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "latest", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -34,9 +34,9 @@ import Testing /// Tests that `swiftly install a.b` installs the latest patch version of Swift a.b. @Test(.testHomeMockedToolchain()) func installLatestPatchVersion() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected swiftly install latest to install release toolchain but installed toolchains is empty in config") @@ -60,7 +60,7 @@ import Testing @Test(.testHomeMockedToolchain()) func installReleases() async throws { var installedToolchains: Set = [] - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(major: 5, minor: 7, patch: 0)) try await SwiftlyTests.validateInstalledToolchains( @@ -68,7 +68,7 @@ import Testing description: "install a stable release toolchain" ) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.2", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.2", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(major: 5, minor: 7, patch: 2)) try await SwiftlyTests.validateInstalledToolchains( @@ -81,7 +81,7 @@ import Testing @Test(.testHomeMockedToolchain()) func installSnapshots() async throws { var installedToolchains: Set = [] - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(snapshotBranch: .main, date: "2023-04-01")) try await SwiftlyTests.validateInstalledToolchains( @@ -89,7 +89,7 @@ import Testing description: "install a main snapshot toolchain" ) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) installedToolchains.insert( ToolchainVersion(snapshotBranch: .release(major: 5, minor: 9), date: "2023-04-01")) @@ -101,9 +101,9 @@ import Testing /// Tests that `swiftly install main-snapshot` installs the latest available main snapshot. @Test(.testHomeMockedToolchain()) func installLatestMainSnapshot() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -128,9 +128,9 @@ import Testing /// Tests that `swiftly install a.b-snapshot` installs the latest available a.b release snapshot. @Test(.testHomeMockedToolchain()) func installLatestReleaseSnapshot() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "6.0-snapshot", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "6.0-snapshot", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -155,11 +155,11 @@ import Testing /// Tests that swiftly can install both stable release toolchains and snapshot toolchains. @Test(.testHomeMockedToolchain()) func installReleaseAndSnapshots() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-03-28", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-03-28", "--post-install-file=\(mktemp())"]) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.8.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.8.0", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInstalledToolchains( [ @@ -174,17 +174,17 @@ import Testing func duplicateTest(_ version: String) async throws { try await SwiftlyTests.withTestHome { try await SwiftlyTests.withMockedToolchain { - try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } } @@ -192,63 +192,63 @@ import Testing /// Tests that attempting to install stable releases that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["5.8.0", "latest"]) func installDuplicateReleases(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Tests that attempting to install main snapshots that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["main-snapshot-2023-04-01", "main-snapshot"]) func installDuplicateMainSnapshots(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Tests that attempting to install release snapshots that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["6.0-snapshot-2024-06-18", "6.0-snapshot"]) func installDuplicateReleaseSnapshots(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Verify that the installed toolchain will be used if no toolchains currently are installed. @Test(.testHomeMockedToolchain()) func installUsesFirstToolchain() async throws { - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) try await SwiftlyTests.validateInUse(expected: nil) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInUse(expected: ToolchainVersion(major: 5, minor: 7, patch: 0)) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.1", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.1", "--post-install-file=\(mktemp())"]) // Verify that 5.7.0 is still in use. try await SwiftlyTests.validateInUse(expected: ToolchainVersion(major: 5, minor: 7, patch: 0)) diff --git a/Tests/SwiftlyTests/PlatformTests.swift b/Tests/SwiftlyTests/PlatformTests.swift index d0e14400..e3e5523d 100644 --- a/Tests/SwiftlyTests/PlatformTests.swift +++ b/Tests/SwiftlyTests/PlatformTests.swift @@ -1,17 +1,18 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct PlatformTests { - func mockToolchainDownload(version: String) async throws -> (URL, ToolchainVersion, URL) { + func mockToolchainDownload(version: String) async throws -> (FilePath, ToolchainVersion, FilePath) { let mockDownloader = MockToolchainDownloader(executables: ["swift"]) let version = try! ToolchainVersion(parsing: version) let ext = Swiftly.currentPlatform.toolchainFileExtension - let tmpDir = Swiftly.currentPlatform.getTempFilePath() - try! FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) - let mockedToolchainFile = tmpDir.appendingPathComponent("swift-\(version).\(ext)") - let mockedToolchain = try await mockDownloader.makeMockedToolchain(toolchain: version, name: tmpDir.path) + let tmpDir = mktemp() + try! await mkdir(atPath: tmpDir, parents: true) + let mockedToolchainFile = tmpDir / "swift-\(version).\(ext)" + let mockedToolchain = try await mockDownloader.makeMockedToolchain(toolchain: version, name: tmpDir.lastComponent!.string) try mockedToolchain.write(to: mockedToolchainFile) return (mockedToolchainFile, version, tmpDir) @@ -23,14 +24,14 @@ import Testing var cleanup = [tmpDir] defer { for dir in cleanup { - try? FileManager.default.removeItem(at: dir) + try? FileManager.default.removeItem(atPath: dir) } } // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchain is extracted in the toolchains directory - var toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + var toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN: a second toolchain has been downloaded @@ -39,7 +40,7 @@ import Testing // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchain is added to the toolchains directory - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(2 == toolchains.count) // GIVEN: an identical toolchain has been downloaded @@ -48,7 +49,7 @@ import Testing // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchains directory remains the same - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(2 == toolchains.count) } @@ -58,7 +59,7 @@ import Testing var cleanup = [tmpDir] defer { for dir in cleanup { - try? FileManager.default.removeItem(at: dir) + try? FileManager.default.removeItem(atPath: dir) } } try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) @@ -68,21 +69,21 @@ import Testing // WHEN: one of the toolchains is uninstalled try await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, version, verbose: true) // THEN: there is only one remaining toolchain installed - var toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + var toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN; there is only one toolchain installed // WHEN: a non-existent toolchain is uninstalled try? await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, ToolchainVersion(parsing: "5.9.1"), verbose: true) // THEN: there is the one remaining toolchain that is still installed - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN: there is only one toolchain installed // WHEN: the last toolchain is uninstalled try await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, ToolchainVersion(parsing: "5.8.0"), verbose: true) // THEN: there are no toolchains installed - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(0 == toolchains.count) } } diff --git a/Tests/SwiftlyTests/RunTests.swift b/Tests/SwiftlyTests/RunTests.swift index 1146d313..edbe0264 100644 --- a/Tests/SwiftlyTests/RunTests.swift +++ b/Tests/SwiftlyTests/RunTests.swift @@ -15,7 +15,7 @@ import Testing #expect(output.contains(ToolchainVersion.newStable.name)) // GIVEN: a set of installed toolchains and one is selected with a .swift-version file - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" try ToolchainVersion.oldStable.name.write(to: versionFile, atomically: true, encoding: .utf8) // WHEN: invoking the run command without any selector arguments for toolchains output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", "swift", "--version"]) @@ -38,7 +38,7 @@ import Testing // 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) - #expect(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).path)) + #expect(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).string)) } /// Tests the extraction of proxy arguments from the run command arguments. diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index da92e593..fd4d06db 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -14,6 +14,8 @@ import MacOSPlatform import AsyncHTTPClient import NIO +import SystemPackage + struct SwiftlyTestError: LocalizedError { let message: String } @@ -47,8 +49,8 @@ struct HTTPRequestExecutorFail: HTTPRequestExecutor { // Convenience extensions to common Swiftly and SwiftlyCore types to set the correct context extension Config { - public static func load() throws -> Config { - try Config.load(SwiftlyTests.ctx) + public static func load() async throws -> Config { + try await Config.load(SwiftlyTests.ctx) } public func save() throws { @@ -58,15 +60,15 @@ extension Config { extension SwiftlyCoreContext { public init( - mockedHomeDir: URL?, + mockedHomeDir: FilePath?, httpRequestExecutor: HTTPRequestExecutor, outputHandler: (any OutputHandler)?, inputProvider: (any InputProvider)? ) { - self.init() + self.init(httpClient: SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor)) self.mockedHomeDir = mockedHomeDir - self.currentDirectory = mockedHomeDir ?? URL.currentDirectory() + self.currentDirectory = mockedHomeDir ?? cwd self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor) self.outputHandler = outputHandler self.inputProvider = inputProvider @@ -159,7 +161,7 @@ extension Trait where Self == TestHomeMockedToolchainTrait { public enum SwiftlyTests { @TaskLocal static var ctx: SwiftlyCoreContext = .init( - mockedHomeDir: URL(fileURLWithPath: "/does/not/exist"), + mockedHomeDir: .init("/does/not/exist"), httpRequestExecutor: HTTPRequestExecutorFail(), outputHandler: OutputHandlerFail(), inputProvider: InputProviderFail() @@ -219,8 +221,8 @@ public enum SwiftlyTests { return await handler.lines } - static func getTestHomePath(name: String) -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-tests-\(name)-\(UUID())") + static func getTestHomePath(name: String) -> FilePath { + tmpDir / "swiftly-tests-\(name)-\(UUID())" } /// Create a fresh swiftly home directory, populate it with a base config, and run the provided closure. @@ -233,10 +235,6 @@ public enum SwiftlyTests { ) async throws { let testHome = Self.getTestHomePath(name: name) - defer { - try? FileManager.default.removeItem(atPath: testHome.path) - } - let ctx = SwiftlyCoreContext( mockedHomeDir: testHome, httpRequestExecutor: SwiftlyTests.ctx.httpClient.httpRequestExecutor, @@ -244,22 +242,18 @@ public enum SwiftlyTests { inputProvider: nil ) - for dir in Swiftly.requiredDirectories(ctx) { - try dir.deleteIfExists() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: false) - } - - defer { + try await withTemporary(files: [testHome] + Swiftly.requiredDirectories(ctx)) { for dir in Swiftly.requiredDirectories(ctx) { - try? FileManager.default.removeItem(at: dir) + try FileManager.default.deleteIfExists(atPath: dir) + try await mkdir(atPath: dir) } - } - let config = try await Self.baseTestConfig() - try config.save(ctx) + let config = try await Self.baseTestConfig() + try config.save(ctx) - try await Self.$ctx.withValue(ctx) { - try await f() + try await Self.$ctx.withValue(ctx) { + try await f() + } } } @@ -276,20 +270,29 @@ public enum SwiftlyTests { try await Self.installMockedToolchain(toolchain: toolchain) } + var cleanBinDir = false + if !toolchains.isEmpty { try await Self.runCommand(Use.self, ["use", inUse?.name ?? "latest"]) } else { - try FileManager.default.createDirectory( - at: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx), - withIntermediateDirectories: true + try await mkdir( + atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx), + parents: true ) + cleanBinDir = true + } + + do { + try await f() - defer { - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) + if cleanBinDir { + try await remove(atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) + } + } catch { + if cleanBinDir { + try await remove(atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) } } - - try await f() } } @@ -329,7 +332,7 @@ public enum SwiftlyTests { /// configuration file and by executing `swift --version` using the swift executable in the `bin` directory. /// If nil is provided, this validates that no toolchain is currently in use. static func validateInUse(expected: ToolchainVersion?) async throws { - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == expected) } @@ -338,7 +341,7 @@ public enum SwiftlyTests { /// This method ensures that config.json reflects the expected installed toolchains and also /// validates that the toolchains on disk match their expected versions via `swift --version`. static func validateInstalledToolchains(_ toolchains: Set, description: String) async throws { - let config = try Config.load() + let config = try await Config.load() guard config.installedToolchains == toolchains else { throw SwiftlyTestError(message: "\(description): expected \(toolchains) but got \(config.installedToolchains)") @@ -346,13 +349,10 @@ public enum SwiftlyTests { #if os(macOS) for toolchain in toolchains { - let toolchainDir = Self.ctx.mockedHomeDir!.appendingPathComponent("Toolchains/\(toolchain.identifier).xctoolchain") - #expect(toolchainDir.fileExists()) + let toolchainDir = Self.ctx.mockedHomeDir! / "Toolchains/\(toolchain.identifier).xctoolchain" + #expect(try await fileExists(atPath: toolchainDir)) - let swiftBinary = toolchainDir - .appendingPathComponent("usr") - .appendingPathComponent("bin") - .appendingPathComponent("swift") + let swiftBinary = toolchainDir / "usr/bin/swift" let executable = SwiftExecutable(path: swiftBinary) let actualVersion = try await executable.version() @@ -361,14 +361,10 @@ public enum SwiftlyTests { #elseif os(Linux) // Verify that the toolchains on disk correspond to those in the config. for toolchain in toolchains { - let toolchainDir = Swiftly.currentPlatform.swiftlyHomeDir(Self.ctx) - .appendingPathComponent("toolchains/\(toolchain.name)") - #expect(toolchainDir.fileExists()) + let toolchainDir = Swiftly.currentPlatform.swiftlyHomeDir(Self.ctx) / "toolchains/\(toolchain.name)" + #expect(try await fileExists(atPath: toolchainDir)) - let swiftBinary = toolchainDir - .appendingPathComponent("usr") - .appendingPathComponent("bin") - .appendingPathComponent("swift") + let swiftBinary = toolchainDir / "usr/bin/swift" let executable = SwiftExecutable(path: swiftBinary) let actualVersion = try await executable.version() @@ -383,7 +379,7 @@ public enum SwiftlyTests { /// When executed, the mocked executables will simply print the toolchain version and return. static func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws { try await Self.withMockedToolchain(executables: executables) { - try await Self.runCommand(Install.self, ["install", "\(selector)", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + args) + try await Self.runCommand(Install.self, ["install", "\(selector)", "--no-verify", "--post-install-file=\(mktemp())"] + args) } } @@ -403,10 +399,10 @@ public enum SwiftlyTests { try await Self.installMockedToolchain(selector: "\(selector)", executables: executables) } - /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given URL. - static func getMockedToolchainVersion(at url: URL) throws -> ToolchainVersion { + /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given FilePath. + static func getMockedToolchainVersion(at path: FilePath) throws -> ToolchainVersion { let process = Process() - process.executableURL = url + process.executableURL = URL(fileURLWithPath: path.string) let outputPipe = Pipe() process.standardOutput = outputPipe @@ -415,7 +411,7 @@ public enum SwiftlyTests { process.waitUntilExit() guard let outputData = try outputPipe.fileHandleForReading.readToEnd() else { - throw SwiftlyTestError(message: "got no output from swift binary at path \(url.path)") + throw SwiftlyTestError(message: "got no output from swift binary at path \(path)") } let toolchainVersion = String(decoding: outputData, as: UTF8.self).trimmingCharacters(in: .newlines) @@ -455,25 +451,25 @@ public actor TestInputProvider: SwiftlyCore.InputProvider { /// Wrapper around a `swift` executable used to execute swift commands. public struct SwiftExecutable { - public let path: URL + public let path: FilePath private static func stableRegex() -> Regex<(Substring, Substring)> { try! Regex("swift-([^-]+)-RELEASE") } - public func exists() -> Bool { - self.path.fileExists() + public func exists() async throws -> Bool { + try await fileExists(atPath: self.path) } /// Gets the version of this executable by parsing the `swift --version` output, potentially looking /// up the commit hash via the GitHub API. public func version() async throws -> ToolchainVersion { let process = Process() - process.executableURL = self.path + process.executableURL = URL(fileURLWithPath: self.path.string) process.arguments = ["--version"] let binPath = ProcessInfo.processInfo.environment["PATH"]! - process.environment = ["PATH": "\(self.path.deletingLastPathComponent().path):\(binPath)"] + process.environment = ["PATH": "\(self.path.removingLastComponent()):\(binPath)"] let outputPipe = Pipe() process.standardOutput = outputPipe @@ -482,7 +478,7 @@ public struct SwiftExecutable { process.waitUntilExit() guard let outputData = try outputPipe.fileHandleForReading.readToEnd() else { - throw SwiftlyTestError(message: "got no output from swift binary at path \(self.path.path)") + throw SwiftlyTestError(message: "got no output from swift binary at path \(self.path)") } let outputString = String(decoding: outputData, as: UTF8.self).trimmingCharacters(in: .newlines) @@ -658,7 +654,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { URL(string: "https://download.swift.org/\(toolchainFile.category)/\(toolchainFile.platform)/\(toolchainFile.version)/\(toolchainFile.file)\(isSignature ? ".sig" : "")")! } - private func makeToolchainDownloadResponse(from url: URL) throws -> OpenAPIRuntime.HTTPBody { + private func makeToolchainDownloadResponse(from url: URL) async throws -> OpenAPIRuntime.HTTPBody { let toolchain: ToolchainVersion if let match = try Self.releaseURLRegex().firstMatch(in: url.path) { var version = "\(match.output.1).\(match.output.2)." @@ -678,26 +674,31 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "invalid toolchain download URL: \(url.path)") } - let mockedToolchain = try self.makeMockedToolchain(toolchain: toolchain, name: url.lastPathComponent) + let mockedToolchain = try await self.makeMockedToolchain(toolchain: toolchain, name: url.lastPathComponent) return HTTPBody(mockedToolchain) } public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile)) + try await self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile)) } public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile, isSignature: true)) + try await self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile, isSignature: true)) } public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - try HTTPBody(Array(self.makeMockedSwiftly(from: url))) + let mockedSwiftly = try await self.makeMockedSwiftly(from: url) + + return HTTPBody(Array(mockedSwiftly)) } public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - try HTTPBody(Array(self.makeMockedSwiftly(from: url))) + let mockedSwiftly = try await self.makeMockedSwiftly(from: url) + + // FIXME: the release signature shouldn't be a mocked swiftly itself + return HTTPBody(Array(mockedSwiftly)) } public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { @@ -706,7 +707,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { } #if os(Linux) - public func makeMockedSwiftly(from url: URL) throws -> Data { + public func makeMockedSwiftly(from url: URL) async throws -> Data { // Check our cache if this is a signature request if url.path.hasSuffix(".sig") { // Signatures will either be in the cache or this don't exist @@ -717,19 +718,15 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { return signature } - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } - let swiftlyDir = tmp.appendingPathComponent("swiftly", isDirectory: true) + let tmp = mktemp() + let gpgKeyFile = mktemp(ext: ".asc") - try FileManager.default.createDirectory( - at: swiftlyDir, - withIntermediateDirectories: true - ) + let swiftlyDir = tmp / "swiftly" + + try await mkdir(atPath: swiftlyDir, parents: true) for executable in ["swiftly"] { - let executablePath = swiftlyDir.appendingPathComponent(executable) + let executablePath = swiftlyDir / executable let script = """ #!/usr/bin/env sh @@ -741,32 +738,27 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let archive = tmp.appendingPathComponent("swiftly.tar.gz") + let archive = tmp / "swiftly.tar.gz" let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["bash", "-c", "tar -C \(swiftlyDir.path) -czf \(archive.path) swiftly"] + task.arguments = ["bash", "-c", "tar -C \(swiftlyDir) -czf \(archive) swiftly"] try task.run() task.waitUntilExit() // Extra step involves generating a gpg signature and putting that in a cache for a later request. We will // use a local key for this to avoid running into entropy problems in CI. - let gpgKeyFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - try Data(PackageResources.mock_signing_key_private_pgp).write(to: gpgKeyFile) - defer { - try? FileManager.default.removeItem(at: gpgKeyFile) - } let importKey = Process() importKey.executableURL = URL(fileURLWithPath: "/usr/bin/env") importKey.arguments = ["bash", "-c", """ - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" - gpg --batch --import \(gpgKeyFile.path) >/dev/null 2>&1 || echo -n + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" + gpg --batch --import \(gpgKeyFile) >/dev/null 2>&1 || echo -n """] try importKey.run() importKey.waitUntilExit() @@ -778,12 +770,12 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { detachSign.executableURL = URL(fileURLWithPath: "/usr/bin/env") detachSign.arguments = ["bash", "-c", """ export GPG_TTY=$(tty) - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" gpg --version | grep '2.0.' > /dev/null if [ "$?" == "0" ]; then - gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" else - gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" fi """] try detachSign.run() @@ -793,12 +785,20 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "unable to sign archive using the test user's gpg key") } - self.signatures["swiftly"] = try Data(contentsOf: archive.appendingPathExtension("sig")) + var signature = archive + signature.extension = "gz.sig" - return try Data(contentsOf: archive) + self.signatures["swiftly"] = try Data(contentsOf: signature) + + let data = try Data(contentsOf: archive) + + try await remove(atPath: tmp) + try await remove(atPath: gpgKeyFile) + + return data } - public func makeMockedToolchain(toolchain: ToolchainVersion, name: String) throws -> Data { + public func makeMockedToolchain(toolchain: ToolchainVersion, name: String) async throws -> Data { // Check our cache if this is a signature request if name.hasSuffix(".sig") { // Signatures will either be in the cache or they don't exist @@ -809,23 +809,16 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { return signature } - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + let tmp = mktemp() + let gpgKeyFile = mktemp(ext: ".asc") - let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) - let toolchainBinDir = toolchainDir - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) + let toolchainDir = tmp / "toolchain" + let toolchainBinDir = toolchainDir / "usr/bin" - try FileManager.default.createDirectory( - at: toolchainBinDir, - withIntermediateDirectories: true - ) + try await mkdir(atPath: toolchainBinDir, parents: true) for executable in self.executables { - let executablePath = toolchainBinDir.appendingPathComponent(executable) + let executablePath = toolchainBinDir / executable let script = """ #!/usr/bin/env sh @@ -837,31 +830,27 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let archive = tmp.appendingPathComponent("toolchain.tar.gz") + let archive = tmp / "toolchain.tar.gz" let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["bash", "-c", "tar -C \(tmp.path) -czf \(archive.path) \(toolchainDir.lastPathComponent)"] + task.arguments = ["bash", "-c", "tar -C \(tmp) -czf \(archive) \(toolchainDir.lastComponent!.string)"] try task.run() task.waitUntilExit() // Extra step involves generating a gpg signature and putting that in a cache for a later request. We will // use a local key for this to avoid running into entropy problems in CI. - let gpgKeyFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") try Data(PackageResources.mock_signing_key_private_pgp).write(to: gpgKeyFile) - defer { - try? FileManager.default.removeItem(at: gpgKeyFile) - } let importKey = Process() importKey.executableURL = URL(fileURLWithPath: "/usr/bin/env") importKey.arguments = ["bash", "-c", """ - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" - gpg --batch --import \(gpgKeyFile.path) >/dev/null 2>&1 || echo -n + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" + gpg --batch --import \(gpgKeyFile) >/dev/null 2>&1 || echo -n """] try importKey.run() importKey.waitUntilExit() @@ -873,12 +862,12 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { detachSign.executableURL = URL(fileURLWithPath: "/usr/bin/env") detachSign.arguments = ["bash", "-c", """ export GPG_TTY=$(tty) - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" gpg --version | grep '2.0.' > /dev/null if [ "$?" == "0" ]; then - gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" else - gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" fi """] try detachSign.run() @@ -888,28 +877,33 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "unable to sign archive using the test user's gpg key") } - self.signatures[toolchain.name] = try Data(contentsOf: archive.appendingPathExtension("sig")) + var signature = archive + signature.extension = "gz.sig" + + self.signatures[toolchain.name] = try Data(contentsOf: signature) + + let data = try Data(contentsOf: archive) - return try Data(contentsOf: archive) + try await remove(atPath: tmp) + try await remove(atPath: gpgKeyFile) + + return data } #elseif os(macOS) - public func makeMockedSwiftly(from _: URL) throws -> Data { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + public func makeMockedSwiftly(from _: URL) async throws -> Data { + let tmp = mktemp() - let swiftlyDir = tmp.appendingPathComponent(".swiftly", isDirectory: true) - let swiftlyBinDir = swiftlyDir.appendingPathComponent("bin") + let swiftlyDir = tmp.appending(".swiftly") + let swiftlyBinDir = swiftlyDir.appending("bin") - try FileManager.default.createDirectory( - at: swiftlyBinDir, - withIntermediateDirectories: true + try await mkdir( + atPath: swiftlyBinDir, + parents: true ) for executable in ["swiftly"] { - let executablePath = swiftlyBinDir.appendingPathComponent(executable) + let executablePath = swiftlyBinDir.appending(executable) let script = """ #!/usr/bin/env sh @@ -921,47 +915,46 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let pkg = tmp.appendingPathComponent("swiftly.pkg") + let pkg = tmp.appending("swiftly.pkg") let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") task.arguments = [ "pkgbuild", "--root", - swiftlyDir.path, + "\(swiftlyDir)", "--install-location", ".swiftly", "--version", "\(self.latestSwiftlyVersion)", "--identifier", "org.swift.swiftly", - pkg.path, + "\(pkg)", ] try task.run() task.waitUntilExit() - return try Data(contentsOf: pkg) + let data = try Data(contentsOf: pkg) + try await remove(atPath: tmp) + return data } - public func makeMockedToolchain(toolchain: ToolchainVersion, name _: String) throws -> Data { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + public func makeMockedToolchain(toolchain: ToolchainVersion, name _: String) async throws -> Data { + let tmp = mktemp() - let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) - let toolchainBinDir = toolchainDir.appendingPathComponent("usr/bin", isDirectory: true) + let toolchainDir = tmp.appending("toolchain") + let toolchainBinDir = toolchainDir.appending("usr/bin") - try FileManager.default.createDirectory( - at: toolchainBinDir, - withIntermediateDirectories: true + try await mkdir( + atPath: toolchainBinDir, + parents: true ) for executable in self.executables { - let executablePath = toolchainBinDir.appendingPathComponent(executable) + let executablePath = toolchainBinDir.appending(executable) let script = """ #!/usr/bin/env sh @@ -973,7 +966,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } // Add a skeletal Info.plist at the top @@ -981,28 +974,31 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { encoder.outputFormat = .xml let pkgInfo = SwiftPkgInfo(CFBundleIdentifier: "org.swift.swift.mock.\(toolchain.name)") let data = try encoder.encode(pkgInfo) - try data.write(to: toolchainDir.appendingPathComponent("Info.plist")) + try data.write(to: toolchainDir.appending("Info.plist")) - let pkg = tmp.appendingPathComponent("toolchain.pkg") + let pkg = tmp.appending("toolchain.pkg") let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") task.arguments = [ "pkgbuild", "--root", - toolchainDir.path, + "\(toolchainDir)", "--install-location", "Library/Developer/Toolchains/\(toolchain.identifier).xctoolchain", "--version", "\(toolchain.name)", "--identifier", pkgInfo.CFBundleIdentifier, - pkg.path, + "\(pkg)", ] try task.run() task.waitUntilExit() - return try Data(contentsOf: pkg) + let pkgData = try Data(contentsOf: pkg) + try await remove(atPath: tmp) + + return pkgData } #endif diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index 2c4ff401..ec90de20 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -226,19 +226,19 @@ import Testing /// Tests that uninstalling the last toolchain is handled properly and cleans up any symlinks. @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) func uninstallLastToolchain() async throws { _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["y"]) - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) // Ensure all symlinks have been cleaned up. - let symlinks = try FileManager.default.contentsOfDirectory( - atPath: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).path + let symlinks = try await ls( + atPath: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) ) #expect(symlinks == []) } /// Tests that aborting an uninstall works correctly. @Test(.mockHomeToolchains(Self.homeName, toolchains: .allToolchains(), inUse: .oldStable)) func uninstallAbort() async throws { - let preConfig = try Config.load() + let preConfig = try await Config.load() _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["n"]) try await SwiftlyTests.validateInstalledToolchains( .allToolchains(), @@ -246,7 +246,7 @@ import Testing ) // Ensure config did not change. - #expect(try Config.load() == preConfig) + #expect(try await Config.load() == preConfig) } /// Tests that providing the `-y` argument skips the confirmation prompt. @@ -269,7 +269,7 @@ import Testing /// Tests that uninstalling a toolchain that is the global default, but is not in the list of installed toolchains. @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallNotInstalled() async throws { - var config = try Config.load() + var config = try await Config.load() config.inUse = .newMainSnapshot config.installedToolchains.remove(.newMainSnapshot) try config.save() diff --git a/Tests/SwiftlyTests/UpdateTests.swift b/Tests/SwiftlyTests/UpdateTests.swift index a1166368..3cea6df1 100644 --- a/Tests/SwiftlyTests/UpdateTests.swift +++ b/Tests/SwiftlyTests/UpdateTests.swift @@ -8,11 +8,11 @@ import Testing @Test(.testHomeMockedToolchain()) func updateLatest() async throws { try await SwiftlyTests.installMockedToolchain(selector: .latest) - let beforeUpdateConfig = try Config.load() + let beforeUpdateConfig = try await Config.load() - try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) - #expect(try Config.load() == beforeUpdateConfig) + #expect(try await Config.load() == beforeUpdateConfig) try await SwiftlyTests.validateInstalledToolchains( beforeUpdateConfig.installedToolchains, description: "Updating latest toolchain should have no effect" @@ -21,7 +21,7 @@ import Testing /// Verify that attempting to update when no toolchains are installed has no effect. @Test(.testHomeMockedToolchain()) func updateLatestWithNoToolchains() async throws { - try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInstalledToolchains( [], @@ -32,9 +32,9 @@ import Testing /// Verify that updating the latest installed toolchain updates it to the latest available toolchain. @Test(.testHomeMockedToolchain()) func updateLatestToLatest() async throws { try await SwiftlyTests.installMockedToolchain(selector: .stable(major: 5, minor: 9, patch: 0)) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse > .init(major: 5, minor: 9, patch: 0)) @@ -48,9 +48,9 @@ import Testing /// released minor version. @Test(.testHomeMockedToolchain()) func updateToLatestMinor() async throws { try await SwiftlyTests.installMockedToolchain(selector: .stable(major: 5, minor: 9, patch: 0)) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 5) @@ -66,9 +66,9 @@ import Testing @Test(.testHomeMockedToolchain()) func updateToLatestPatch() async throws { try await SwiftlyTests.installMockedToolchain(selector: "5.9.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5.9.0", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5.9.0", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 5) @@ -86,9 +86,9 @@ import Testing @Test(.testHomeMockedToolchain()) func updateGlobalDefault() async throws { try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse > .init(major: 6, minor: 0, patch: 0)) #expect(inUse.major == 6) @@ -108,18 +108,18 @@ import Testing @Test(.testHomeMockedToolchain()) func updateInUse() async throws { try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") - try "6.0.0".write(to: versionFile, atomically: true, encoding: .utf8) + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" + try "6.0.0".write(to: versionFile, atomically: true) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(mktemp())"]) - let versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + let versionFileContents = try String(contentsOf: versionFile) let inUse = try ToolchainVersion(parsing: versionFileContents) #expect(inUse > .init(major: 6, minor: 0, patch: 0)) // Since the global default was set to 6.0.0, and that toolchain is no longer installed // the update should have unset it to prevent the config from going into a bad state. - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) // The new toolchain should be installed @@ -138,10 +138,10 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: .snapshot(branch: branch, date: date)) try await SwiftlyTests.runCommand( - Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(mktemp())"] ) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asSnapshot! #expect(inUse > .init(branch: branch, date: date)) #expect(inUse.branch == branch) @@ -158,9 +158,9 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: "6.0.1") try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "6.0", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "6.0", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 6) #expect(inUse.minor == 0) @@ -185,10 +185,10 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: .snapshot(branch: branch, date: "2024-06-18")) try await SwiftlyTests.runCommand( - Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(mktemp())"] ) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asSnapshot! #expect(inUse.branch == branch) diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 0e216b88..ff642a17 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -11,7 +11,7 @@ import Testing func useAndValidate(argument: String, expectedVersion: ToolchainVersion) async throws { try await SwiftlyTests.runCommand(Use.self, ["use", "-g", argument]) - #expect(try Config.load().inUse == expectedVersion) + #expect(try await Config.load().inUse == expectedVersion) } /// Tests that the `use` command can switch between installed stable release toolchains. @@ -152,12 +152,12 @@ import Testing @Test(.mockHomeToolchains(toolchains: [])) func useNoInstalledToolchains() async throws { try await SwiftlyTests.runCommand(Use.self, ["use", "-g", "latest"]) - var config = try Config.load() + var config = try await Config.load() #expect(config.inUse == nil) try await SwiftlyTests.runCommand(Use.self, ["use", "-g", "5.6.0"]) - config = try Config.load() + config = try await Config.load() #expect(config.inUse == nil) } @@ -175,7 +175,7 @@ import Testing /// Tests that the `use` command works with all the installed toolchains in this test harness. @Test(.mockHomeToolchains()) func useAll() async throws { - let config = try Config.load() + let config = try await Config.load() for toolchain in config.installedToolchains { try await self.useAndValidate( @@ -202,7 +202,7 @@ import Testing output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", "-g", "--print-location"]) - #expect(output.contains(where: { $0.contains(Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchain).path) })) + #expect(output.contains(where: { $0.contains(Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchain).string) })) } } } @@ -215,10 +215,10 @@ import Testing .newReleaseSnapshot, ] try await SwiftlyTests.withMockedHome(homeName: Self.homeName, toolchains: Set(toolchains)) { - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" // GIVEN: a directory with a swift version file that selects a particular toolchain - try ToolchainVersion.newStable.name.write(to: versionFile, atomically: true, encoding: .utf8) + try ToolchainVersion.newStable.name.write(to: versionFile, atomically: true) // WHEN: checking which toolchain is selected with the use command var output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use"]) // THEN: the output shows this toolchain is in use with this working directory @@ -228,30 +228,30 @@ import Testing // WHEN: using another toolchain version output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", ToolchainVersion.newMainSnapshot.name]) // THEN: the swift version file is updated to this toolchain version - var versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + var versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newMainSnapshot.name == versionFileContents) // THEN: the use command reports this toolchain to be in use #expect(output.contains(where: { $0.contains(ToolchainVersion.newMainSnapshot.name) })) // GIVEN: a directory with no swift version file at the top of a git repository - try FileManager.default.removeItem(atPath: versionFile.path) - let gitDir = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".git") - try FileManager.default.createDirectory(atPath: gitDir.path, withIntermediateDirectories: false) + try await remove(atPath: versionFile) + let gitDir = SwiftlyTests.ctx.currentDirectory / ".git" + try await mkdir(atPath: gitDir) // WHEN: using a toolchain version try await SwiftlyTests.runCommand(Use.self, ["use", ToolchainVersion.newReleaseSnapshot.name]) // THEN: a swift version file is created - #expect(FileManager.default.fileExists(atPath: versionFile.path)) + #expect(try await fileExists(atPath: versionFile)) // THEN: the version file contains the specified version - versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newReleaseSnapshot.name == versionFileContents) // GIVEN: a directory with a swift version file at the top of a git repository - try "1.2.3".write(to: versionFile, atomically: true, encoding: .utf8) + try "1.2.3".write(to: versionFile, atomically: true) // WHEN: using with a toolchain selector that can select more than one version, but matches one of the installed toolchains let broadSelector = ToolchainSelector.stable(major: ToolchainVersion.newStable.asStableRelease!.major, minor: nil, patch: nil) try await SwiftlyTests.runCommand(Use.self, ["use", broadSelector.description]) // THEN: the swift version file is set to the specific toolchain version that was installed including major, minor, and patch - versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newStable.name == versionFileContents) } }