diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 3e233d3a..cdc49088 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -2,6 +2,7 @@ import Foundation import SwiftlyCore import SystemPackage +typealias sys = SwiftlyCore.SystemCommand typealias fs = SwiftlyCore.FileSystem /// `Platform` implementation for Linux systems. @@ -605,15 +606,8 @@ public struct Linux: Platform { public func getShell() async throws -> String { let userName = ProcessInfo.processInfo.userName - let prefix = "\(userName):" - if let passwds = try await runProgramOutput("getent", "passwd") { - for line in passwds.components(separatedBy: "\n") { - if line.hasPrefix(prefix) { - if case let comps = line.components(separatedBy: ":"), comps.count > 1 { - return comps[comps.count - 1] - } - } - } + if let entry = try await sys.getent(database: "passwd", keys: userName).entries(self).first { + if let shell = entry.last { return shell } } // Fall back on bash on Linux and other Unixes diff --git a/Sources/SwiftlyCore/Commands.swift b/Sources/SwiftlyCore/Commands.swift index ad837104..83826ff1 100644 --- a/Sources/SwiftlyCore/Commands.swift +++ b/Sources/SwiftlyCore/Commands.swift @@ -30,7 +30,7 @@ extension SystemCommand { var args: [String] = [] if let datasource = self.datasource { - args += [datasource] + args.append(datasource) } return Configuration( @@ -65,9 +65,11 @@ extension SystemCommand { var args = c.arguments.storage.map(\.description) + ["-read"] if let path = self.path { - args += [path.string] + self.keys + args.append(path.string) } + args.append(contentsOf: self.keys) + c.arguments = .init(args) return c @@ -91,6 +93,8 @@ extension SystemCommand.DsclCommand.ReadCommand: Output { } } +// Create or operate on universal files +// See lipo(1) for details extension SystemCommand { public static func lipo(executable: Executable = LipoCommand.defaultExecutable, inputFiles: FilePath...) -> LipoCommand { Self.lipo(executable: executable, inputFiles: inputFiles) @@ -106,7 +110,7 @@ extension SystemCommand { var executable: Executable var inputFiles: [FilePath] - public init(executable: Executable, inputFiles: [FilePath]) { + internal init(executable: Executable, inputFiles: [FilePath]) { self.executable = executable self.inputFiles = inputFiles } @@ -114,7 +118,7 @@ extension SystemCommand { func config() -> Configuration { var args: [String] = [] - args += self.inputFiles.map(\.string) + args.append(contentsOf: self.inputFiles.map(\.string)) return Configuration( executable: self.executable, @@ -150,3 +154,334 @@ extension SystemCommand { } extension SystemCommand.LipoCommand.CreateCommand: Runnable {} + +// Build a macOS Installer component package from on-disk files +// See pkgbuild(1) for more details +extension SystemCommand { + public static func pkgbuild(executable: Executable = PkgbuildCommand.defaultExecutable, _ options: PkgbuildCommand.Option..., root: FilePath, packageOutputPath: FilePath) -> PkgbuildCommand { + Self.pkgbuild(executable: executable, options: options, root: root, packageOutputPath: packageOutputPath) + } + + public static func pkgbuild(executable: Executable = PkgbuildCommand.defaultExecutable, options: [PkgbuildCommand.Option], root: FilePath, packageOutputPath: FilePath) -> PkgbuildCommand { + PkgbuildCommand(executable: executable, options, root: root, packageOutputPath: packageOutputPath) + } + + public struct PkgbuildCommand { + public static var defaultExecutable: Executable { .name("pkgbuild") } + + var executable: Executable + + var options: [Option] + + var root: FilePath + var packageOutputPath: FilePath + + internal init(executable: Executable, _ options: [Option], root: FilePath, packageOutputPath: FilePath) { + self.executable = executable + self.options = options + self.root = root + self.packageOutputPath = packageOutputPath + } + + public enum Option { + case installLocation(FilePath) + case version(String) + case identifier(String) + case sign(String) + + func args() -> [String] { + switch self { + case let .installLocation(installLocation): + return ["--install-location", installLocation.string] + case let .version(version): + return ["--version", version] + case let .identifier(identifier): + return ["--identifier", identifier] + case let .sign(identityName): + return ["--sign", identityName] + } + } + } + + public func config() -> Configuration { + var args: [String] = [] + + for option in self.options { + args.append(contentsOf: option.args()) + } + + args.append(contentsOf: ["--root", "\(self.root)"]) + args.append("\(self.packageOutputPath)") + + return Configuration( + executable: self.executable, + arguments: Arguments(args), + environment: .inherit + ) + } + } +} + +extension SystemCommand.PkgbuildCommand: Runnable {} + +// get entries from Name Service Switch libraries +// See getent(1) for more details +extension SystemCommand { + public static func getent(executable: Executable = GetentCommand.defaultExecutable, database: String, keys: String...) -> GetentCommand { + Self.getent(executable: executable, database: database, keys: keys) + } + + public static func getent(executable: Executable = GetentCommand.defaultExecutable, database: String, keys: [String]) -> GetentCommand { + GetentCommand(executable: executable, database: database, keys: keys) + } + + public struct GetentCommand { + public static var defaultExecutable: Executable { .name("getent") } + + var executable: Executable + + var database: String + + var keys: [String] + + internal init( + executable: Executable, + database: String, + keys: [String] + ) { + self.executable = executable + self.database = database + self.keys = keys + } + + public func config() -> Configuration { + var args: [String] = [] + + args.append(self.database) + args.append(contentsOf: self.keys) + + return Configuration( + executable: self.executable, + arguments: Arguments(args), + environment: .inherit + ) + } + } +} + +extension SystemCommand.GetentCommand: Output { + public func entries(_ platform: Platform) async throws -> [[String]] { + let output = try await output(platform) + guard let output else { return [] } + + var entries: [[String]] = [] + for line in output.components(separatedBy: "\n") { + entries.append(line.components(separatedBy: ":")) + } + return entries + } +} + +extension SystemCommand { + public static func git(executable: Executable = GitCommand.defaultExecutable, workingDir: FilePath? = nil) -> GitCommand { + GitCommand(executable: executable, workingDir: workingDir) + } + + public struct GitCommand { + public static var defaultExecutable: Executable { .name("git") } + + var executable: Executable + + var workingDir: FilePath? + + internal init(executable: Executable, workingDir: FilePath?) { + self.executable = executable + self.workingDir = workingDir + } + + func config() -> Configuration { + var args: [String] = [] + + if let workingDir { + args.append(contentsOf: ["-C", "\(workingDir)"]) + } + + return Configuration( + executable: self.executable, + arguments: Arguments(args), + environment: .inherit + ) + } + + public func _init() -> InitCommand { + InitCommand(self) + } + + public struct InitCommand { + var git: GitCommand + + internal init(_ git: GitCommand) { + self.git = git + } + + public func config() -> Configuration { + var c = self.git.config() + + var args = c.arguments.storage.map(\.description) + + args.append("init") + + c.arguments = .init(args) + + return c + } + } + + public func commit(_ options: CommitCommand.Option...) -> CommitCommand { + self.commit(options: options) + } + + public func commit(options: [CommitCommand.Option]) -> CommitCommand { + CommitCommand(self, options: options) + } + + public struct CommitCommand { + var git: GitCommand + + var options: [Option] + + internal init(_ git: GitCommand, options: [Option]) { + self.git = git + self.options = options + } + + public enum Option { + case allowEmpty + case allowEmptyMessage + case message(String) + + public func args() -> [String] { + switch self { + case .allowEmpty: + ["--allow-empty"] + case .allowEmptyMessage: + ["--allow-empty-message"] + case let .message(message): + ["-m", message] + } + } + } + + public func config() -> Configuration { + var c = self.git.config() + + var args = c.arguments.storage.map(\.description) + + args.append("commit") + for option in self.options { + args.append(contentsOf: option.args()) + } + + c.arguments = .init(args) + + return c + } + } + + public func log(_ options: LogCommand.Option...) -> LogCommand { + LogCommand(self, options) + } + + public struct LogCommand { + var git: GitCommand + var options: [Option] + + internal init(_ git: GitCommand, _ options: [Option]) { + self.git = git + self.options = options + } + + public enum Option { + case maxCount(Int) + case pretty(String) + + func args() -> [String] { + switch self { + case let .maxCount(num): + return ["--max-count=\(num)"] + case let .pretty(format): + return ["--pretty=\(format)"] + } + } + } + + public func config() -> Configuration { + var c = self.git.config() + + var args = c.arguments.storage.map(\.description) + + args.append("log") + + for opt in self.options { + args.append(contentsOf: opt.args()) + } + + c.arguments = .init(args) + + return c + } + } + + public func diffIndex(_ options: DiffIndexCommand.Option..., treeIsh: String?) -> DiffIndexCommand { + DiffIndexCommand(self, options, treeIsh: treeIsh) + } + + public struct DiffIndexCommand { + var git: GitCommand + var options: [Option] + var treeIsh: String? + + internal init(_ git: GitCommand, _ options: [Option], treeIsh: String?) { + self.git = git + self.options = options + self.treeIsh = treeIsh + } + + public enum Option { + case quiet + + func args() -> [String] { + switch self { + case .quiet: + return ["--quiet"] + } + } + } + + public func config() -> Configuration { + var c = self.git.config() + + var args = c.arguments.storage.map(\.description) + + args.append("diff-index") + + for opt in self.options { + args.append(contentsOf: opt.args()) + } + + if let treeIsh = self.treeIsh { + args.append(treeIsh) + } + + c.arguments = .init(args) + + return c + } + } + } +} + +extension SystemCommand.GitCommand.LogCommand: Output {} +extension SystemCommand.GitCommand.DiffIndexCommand: Runnable {} +extension SystemCommand.GitCommand.InitCommand: Runnable {} +extension SystemCommand.GitCommand.CommitCommand: Runnable {} diff --git a/Sources/SwiftlyCore/ModeledCommandLine.swift b/Sources/SwiftlyCore/ModeledCommandLine.swift index cb07f372..9432b81b 100644 --- a/Sources/SwiftlyCore/ModeledCommandLine.swift +++ b/Sources/SwiftlyCore/ModeledCommandLine.swift @@ -121,6 +121,34 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { } } +// Provide string representations of Configuration +extension Executable: CustomStringConvertible { + public var description: String { + switch self.storage { + case let .executable(name): + name + case let .path(path): + path.string + } + } +} + +extension Arguments: CustomStringConvertible { + public var description: String { + let normalized: [String] = self.storage.map(\.description).map { + $0.contains(" ") ? "\"\($0)\"" : String($0) + } + + return normalized.joined(separator: " ") + } +} + +extension Configuration: CustomStringConvertible { + public var description: String { + "\(self.executable) \(self.arguments)" + } +} + public protocol Runnable { func config() -> Configuration } diff --git a/Tests/SwiftlyTests/CommandLineTests.swift b/Tests/SwiftlyTests/CommandLineTests.swift index fb478fad..acf65c4c 100644 --- a/Tests/SwiftlyTests/CommandLineTests.swift +++ b/Tests/SwiftlyTests/CommandLineTests.swift @@ -39,4 +39,76 @@ public struct CommandLineTests { #expect(config.executable == .name("lipo")) #expect(config.arguments.storage.map(\.description) == ["swiftly", "-create", "-output", "swiftly-universal-with-one-arch"]) } + + @Test func testPkgbuild() { + var config = sys.pkgbuild(root: "mypath", packageOutputPath: "outputDir").config() + #expect(String(describing: config) == "pkgbuild --root mypath outputDir") + + config = sys.pkgbuild(.version("1234"), root: "somepath", packageOutputPath: "output").config() + #expect(String(describing: config) == "pkgbuild --version 1234 --root somepath output") + + config = sys.pkgbuild(.installLocation("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), .sign("mycert"), root: "someroot", packageOutputPath: "my.pkg").config() + #expect(String(describing: config) == "pkgbuild --install-location /usr/local --version 1.0.0 --identifier org.foo.bar --sign mycert --root someroot my.pkg") + + config = sys.pkgbuild(.installLocation("/usr/local"), .version("1.0.0"), .identifier("org.foo.bar"), root: "someroot", packageOutputPath: "my.pkg").config() + #expect(String(describing: config) == "pkgbuild --install-location /usr/local --version 1.0.0 --identifier org.foo.bar --root someroot my.pkg") + } + + @Test func testGetent() { + var config = sys.getent(database: "passwd", keys: "swiftly").config() + #expect(String(describing: config) == "getent passwd swiftly") + + config = sys.getent(database: "foo", keys: "abc", "def").config() + #expect(String(describing: config) == "getent foo abc def") + } + + @Test func testGitModel() { + var config = sys.git().log(.maxCount(1), .pretty("format:%d")).config() + #expect(String(describing: config) == "git log --max-count=1 --pretty=format:%d") + + config = sys.git().log().config() + #expect(String(describing: config) == "git log") + + config = sys.git().log(.pretty("foo")).config() + #expect(String(describing: config) == "git log --pretty=foo") + + config = sys.git().diffIndex(.quiet, treeIsh: "HEAD").config() + #expect(String(describing: config) == "git diff-index --quiet HEAD") + + config = sys.git().diffIndex(treeIsh: "main").config() + #expect(String(describing: config) == "git diff-index main") + } + + @Test( + .tags(.medium), + .enabled { + try await sys.GitCommand.defaultExecutable.exists() + } + ) + func testGit() async throws { + // GIVEN a simple git repository + let tmp = fs.mktemp() + try await fs.mkdir(atPath: tmp) + try await sys.git(workingDir: tmp)._init().run(Swiftly.currentPlatform) + + // AND a simple history + try "Some text".write(to: tmp / "foo.txt", atomically: true) + try await Swiftly.currentPlatform.runProgram("git", "-C", "\(tmp)", "add", "foo.txt") + try await Swiftly.currentPlatform.runProgram("git", "-C", "\(tmp)", "config", "user.email", "user@example.com") + try await sys.git(workingDir: tmp).commit(.message("Initial commit")).run(Swiftly.currentPlatform) + try await sys.git(workingDir: tmp).diffIndex(.quiet, treeIsh: "HEAD").run(Swiftly.currentPlatform) + + // WHEN inspecting the log + let log = try await sys.git(workingDir: tmp).log(.maxCount(1)).output(Swiftly.currentPlatform)! + // THEN it is not empty + #expect(log != "") + + // WHEN there is a change to the work tree + try "Some new text".write(to: tmp / "foo.txt", atomically: true) + + // THEN diff index finds a change + try await #expect(throws: Error.self) { + try await sys.git(workingDir: tmp).diffIndex(.quiet, treeIsh: "HEAD").run(Swiftly.currentPlatform) + } + } } diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index e74ee1a9..fa74f738 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -940,22 +940,14 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { let pkg = tmp / "swiftly.pkg" - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = [ - "pkgbuild", - "--root", - "\(swiftlyDir)", - "--install-location", - ".swiftly", - "--version", - "\(self.latestSwiftlyVersion)", - "--identifier", - "org.swift.swiftly", - "\(pkg)", - ] - try task.run() - task.waitUntilExit() + try await sys.pkgbuild( + .installLocation("swiftly"), + .version("\(self.latestSwiftlyVersion)"), + .identifier("org.swift.swiftly"), + root: swiftlyDir, + packageOutputPath: pkg + ) + .run(Swiftly.currentPlatform) let data = try Data(contentsOf: pkg) try await fs.remove(atPath: tmp) @@ -993,24 +985,16 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { let data = try encoder.encode(pkgInfo) try data.write(to: toolchainDir.appending("Info.plist")) - let pkg = tmp.appending("toolchain.pkg") + let pkg = tmp / "toolchain.pkg" - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = [ - "pkgbuild", - "--root", - "\(toolchainDir)", - "--install-location", - "Library/Developer/Toolchains/\(toolchain.identifier).xctoolchain", - "--version", - "\(toolchain.name)", - "--identifier", - pkgInfo.CFBundleIdentifier, - "\(pkg)", - ] - try task.run() - task.waitUntilExit() + try await sys.pkgbuild( + .installLocation(FilePath("Library/Developer/Toolchains/\(toolchain.identifier).xctoolchain")), + .version("\(toolchain.name)"), + .identifier(pkgInfo.CFBundleIdentifier), + root: toolchainDir, + packageOutputPath: pkg + ) + .run(Swiftly.currentPlatform) let pkgData = try Data(contentsOf: pkg) try await fs.remove(atPath: tmp) diff --git a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift index b5ead917..5b3c472f 100644 --- a/Tools/build-swiftly-release/BuildSwiftlyRelease.swift +++ b/Tools/build-swiftly-release/BuildSwiftlyRelease.swift @@ -18,6 +18,17 @@ let currentPlatform = Linux() typealias fs = FileSystem typealias sys = SystemCommand +extension Runnable { + // Runs the command while echoing the full command-line to stdout for logging and reproduction + func runEcho(_ platform: Platform, quiet: Bool = false) async throws { + let config = self.config() + // if !quiet { print("\(args.joined(separator: " "))") } + if !quiet { print("\(config)") } + + try await self.run(platform) + } +} + public struct SwiftPlatform: Codable { public var name: String? public var checksum: String? @@ -225,17 +236,17 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { return swift } - func checkGitRepoStatus(_ git: String) async throws { + func checkGitRepoStatus(_: String) async throws { guard !self.skip else { return } - guard let gitTags = try await runProgramOutput(git, "log", "-n1", "--pretty=format:%d"), gitTags.contains("tag: \(self.version)") else { + guard let gitTags = try await sys.git().log(.maxCount(1), .pretty("format:%d")).output(currentPlatform), gitTags.contains("tag: \(self.version)") else { throw Error(message: "Git repo is not yet tagged for release \(self.version). Please tag this commit with that version and push it to GitHub.") } do { - try runProgram(git, "diff-index", "--quiet", "HEAD") + try await sys.git().diffIndex(.quiet, treeIsh: "HEAD").run(currentPlatform) } catch { throw Error(message: "Git repo has local changes. First commit these changes, tag the commit with release \(self.version) and push the tag to GitHub.") } @@ -396,7 +407,6 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { try await self.checkGitRepoStatus(git) - let pkgbuild = try await self.assertTool("pkgbuild", message: "In order to make pkg installers there needs to be the `pkgbuild` tool that is installed on macOS.") let strip = try await self.assertTool("strip", message: "In order to strip binaries there needs to be the `strip` tool that is installed on macOS.") let tar = try await self.assertTool("tar", message: "In order to produce archives there needs to be the `tar` tool that is installed on macOS.") @@ -415,7 +425,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { inputFiles: ".build/x86_64-apple-macosx/release/swiftly", ".build/arm64-apple-macosx/release/swiftly" ) .create(output: swiftlyBinDir / "swiftly") - .run(currentPlatform) + .runEcho(currentPlatform) let swiftlyLicenseDir = FileManager.default.currentDirectoryPath + "/.build/release/.swiftly/license" try? FileManager.default.createDirectory(atPath: swiftlyLicenseDir, withIntermediateDirectories: true) @@ -427,33 +437,22 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { let pkgFile = releaseDir.appendingPathComponent("/swiftly-\(self.version).pkg") if let cert { - try runProgram( - pkgbuild, - "--root", - "\(swiftlyBinDir.parent)", - "--install-location", - ".swiftly", - "--version", - self.version, - "--identifier", - identifier, - "--sign", - cert, - ".build/release/swiftly-\(self.version).pkg" - ) + try await sys.pkgbuild( + .installLocation(".swiftly"), + .version(self.version), + .identifier(identifier), + .sign(cert), + root: swiftlyBinDir.parent, + packageOutputPath: FilePath(".build/release/swiftly-\(self.version).pkg") + ).runEcho(currentPlatform) } else { - try runProgram( - pkgbuild, - "--root", - "\(swiftlyBinDir.parent)", - "--install-location", - ".swiftly", - "--version", - self.version, - "--identifier", - identifier, - ".build/release/swiftly-\(self.version).pkg" - ) + try await sys.pkgbuild( + .installLocation(".swiftly"), + .version(self.version), + .identifier(identifier), + root: swiftlyBinDir.parent, + packageOutputPath: FilePath(".build/release/swiftly-\(self.version).pkg") + ).runEcho(currentPlatform) } // Re-configure the pkg to prefer installs into the current user's home directory with the help of productbuild. @@ -490,7 +489,7 @@ struct BuildSwiftlyRelease: AsyncParsableCommand { inputFiles: ".build/x86_64-apple-macosx/debug/test-swiftly", ".build/arm64-apple-macosx/debug/test-swiftly" ) .create(output: swiftlyBinDir / "swiftly") - .run(currentPlatform) + .runEcho(currentPlatform) try runProgram(tar, "--directory=.build/x86_64-apple-macosx/debug", "-czf", testArchive.path, "test-swiftly")