Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ let package = Package(
.executableTarget(
name: "build-swiftly-release",
dependencies: [
.target(name: "SwiftlyCore"),
.target(name: "LinuxPlatform", condition: .when(platforms: [.linux])),
.target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Tools/build-swiftly-release"
Expand Down
11 changes: 3 additions & 8 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import SwiftlyCore
import SystemPackage

typealias sys = SwiftlyCore.SystemCommand
typealias fs = SwiftlyCore.FileSystem

public struct SwiftPkgInfo: Codable {
Expand Down Expand Up @@ -191,14 +192,8 @@ public struct MacOS: Platform {
}

public func getShell() async throws -> String {
if let directoryInfo = try await runProgramOutput("dscl", ".", "-read", "\(fs.home)") {
for line in directoryInfo.components(separatedBy: "\n") {
if line.hasPrefix("UserShell: ") {
if case let comps = line.components(separatedBy: ": "), comps.count == 2 {
return comps[1]
}
}
}
for (key, value) in try await sys.dscl(datasource: ".").read(path: fs.home, keys: "UserShell").properties(self) {
return value
}

// Fall back to zsh on macOS
Expand Down
92 changes: 92 additions & 0 deletions Sources/SwiftlyCore/Commands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Foundation
import SystemPackage

public enum SystemCommand {}

// This file contains a set of system commands that's used by Swiftly and its related tests and tooling

// Directory Service command line utility for macOS
// See dscl(1) for details
extension SystemCommand {
public static func dscl(executable: Executable = DsclCommand.defaultExecutable, datasource: String? = nil) -> DsclCommand {
DsclCommand(executable: executable, datasource: datasource)
}

public struct DsclCommand {
public static var defaultExecutable: Executable { .name("dscl") }

var executable: Executable
var datasource: String?

internal init(
executable: Executable,
datasource: String?
) {
self.executable = executable
self.datasource = datasource
}

func config() -> Configuration {
var args: [String] = []

if let datasource = self.datasource {
args += [datasource]
}

return Configuration(
executable: self.executable,
arguments: Arguments(args),
environment: .inherit
)
}

public func read(path: FilePath? = nil, keys: [String]) -> ReadCommand {
ReadCommand(dscl: self, path: path, keys: keys)
}

public func read(path: FilePath? = nil, keys: String...) -> ReadCommand {
self.read(path: path, keys: keys)
}

public struct ReadCommand: Output {
var dscl: DsclCommand
var path: FilePath?
var keys: [String]

internal init(dscl: DsclCommand, path: FilePath?, keys: [String]) {
self.dscl = dscl
self.path = path
self.keys = keys
}

public func config() -> Configuration {
var c = self.dscl.config()

var args = c.arguments.storage.map(\.description) + ["-read"]

if let path = self.path {
args += [path.string] + self.keys
}

c.arguments = .init(args)

return c
}
}
}
}

extension SystemCommand.DsclCommand.ReadCommand {
public func properties(_ p: Platform) async throws -> [(key: String, value: String)] {
let output = try await self.output(p)
guard let output else { return [] }

var props: [(key: String, value: String)] = []
for line in output.components(separatedBy: "\n") {
if case let comps = line.components(separatedBy: ": "), comps.count == 2 {
props.append((key: comps[0], value: comps[1]))
}
}
return props
}
}
177 changes: 177 additions & 0 deletions Sources/SwiftlyCore/ModeledCommandLine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import Foundation
import SystemPackage

public enum CommandLineError: Error {
case invalidArgs
case errorExit(exitCode: Int32, program: String)
case unknownVersion
}

// This section is a clone of the Configuration type from the new Subprocess package, until we can depend on that package.
public struct Configuration: Sendable {
/// The executable to run.
public var executable: Executable
/// The arguments to pass to the executable.
public var arguments: Arguments
/// The environment to use when running the executable.
public var environment: Environment
}

public struct Executable: Sendable, Hashable {
internal enum Storage: Sendable, Hashable {
case executable(String)
case path(FilePath)
}

internal let storage: Storage

private init(_config: Storage) {
self.storage = _config
}

/// Locate the executable by its name.
/// `Subprocess` will use `PATH` value to
/// determine the full path to the executable.
public static func name(_ executableName: String) -> Self {
.init(_config: .executable(executableName))
}

/// Locate the executable by its full path.
/// `Subprocess` will use this path directly.
public static func path(_ filePath: FilePath) -> Self {
.init(_config: .path(filePath))
}
}

public struct Environment: Sendable, Hashable {
internal enum Configuration: Sendable, Hashable {
case inherit([String: String])
case custom([String: String])
}

internal let config: Configuration

init(config: Configuration) {
self.config = config
}

/// Child process should inherit the same environment
/// values from its parent process.
public static var inherit: Self {
.init(config: .inherit([:]))
}

/// Override the provided `newValue` in the existing `Environment`
public func updating(_ newValue: [String: String]) -> Self {
.init(config: .inherit(newValue))
}

/// Use custom environment variables
public static func custom(_ newValue: [String: String]) -> Self {
.init(config: .custom(newValue))
}
}

internal enum StringOrRawBytes: Sendable, Hashable {
case string(String)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enum seems to be unnecessary w/ only one case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code comes from the new subprocess package, which unfortunately is targeting a newer macOS requirement than swiftly at the moment (should be fixed soon). This is a duplicate of the relevant data structures from there so that it's aligned with it once it's usable here.


var stringValue: String? {
switch self {
case let .string(string):
return string
}
}

var description: String {
switch self {
case let .string(string):
return string
}
}

var count: Int {
switch self {
case let .string(string):
return string.count
}
}

func hash(into hasher: inout Hasher) {
// If Raw bytes is valid UTF8, hash it as so
switch self {
case let .string(string):
hasher.combine(string)
}
}
}

public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable {
public typealias ArrayLiteralElement = String

internal let storage: [StringOrRawBytes]

/// Create an Arguments object using the given literal values
public init(arrayLiteral elements: String...) {
self.storage = elements.map { .string($0) }
}

/// Create an Arguments object using the given array
public init(_ array: [String]) {
self.storage = array.map { .string($0) }
}
}

public protocol Runnable {
func config() -> Configuration
}

extension Runnable {
public func run(_ p: Platform, quiet: Bool) async throws {
let c = self.config()
let executable = switch c.executable.storage {
case let .executable(name):
name
case let .path(p):
p.string
}
let args = c.arguments.storage.map(\.description)
var env: [String: String] = ProcessInfo.processInfo.environment
switch c.environment.config {
case let .inherit(newValue):
for (key, value) in newValue {
env[key] = value
}
case let .custom(newValue):
env = newValue
}
try await p.runProgram([executable] + args, quiet: quiet, env: env)
}
}

public protocol Output {
func config() -> Configuration
}

// TODO: look into making this something that can be Decodable (i.e. streamable)
extension Output {
public func output(_ p: Platform) async throws -> String? {
let c = self.config()
let executable = switch c.executable.storage {
case let .executable(name):
name
case let .path(p):
p.string
}
let args = c.arguments.storage.map(\.description)
var env: [String: String] = ProcessInfo.processInfo.environment
switch c.environment.config {
case let .inherit(newValue):
for (key, value) in newValue {
env[key] = value
}
case let .custom(newValue):
env = newValue
}
return try await p.runProgramOutput(executable, args, env: env)
}
}
31 changes: 31 additions & 0 deletions Tests/SwiftlyTests/CommandLineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@testable import Swiftly
@testable import SwiftlyCore
import SystemPackage
import Testing

public typealias sys = SystemCommand

@Suite
public struct CommandLineTests {
@Test func testDsclModel() {
var config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell").config()
#expect(config.executable == .name("dscl"))
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell"])

config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell", "Picture").config()
#expect(config.executable == .name("dscl"))
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell", "Picture"])
}

@Test(
.tags(.medium),
.enabled {
try await sys.DsclCommand.defaultExecutable.exists()
}
)
func testDscl() async throws {
let properties = try await sys.dscl(datasource: ".").read(path: fs.home, keys: "UserShell").properties(Swiftly.currentPlatform)
#expect(properties.count == 1) // Only one shell for the current user
#expect(properties[0].key == "UserShell") // The one property key should be the one that is requested
}
}
9 changes: 5 additions & 4 deletions Tests/SwiftlyTests/HTTPClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SystemPackage
import Testing

@Suite(.serialized) struct HTTPClientTests {
@Test func getSwiftOrgGPGKeys() async throws {
@Test(.tags(.large)) func getSwiftOrgGPGKeys() async throws {
let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)

Expand All @@ -24,7 +24,7 @@ import Testing
}
}

@Test func getSwiftToolchain() async throws {
@Test(.tags(.large)) func getSwiftToolchain() async throws {
let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)
let tmpFileSignature = fs.mktemp(ext: ".sig")
Expand Down Expand Up @@ -53,7 +53,7 @@ import Testing
}
}

@Test func getSwiftlyRelease() async throws {
@Test(.tags(.large)) func getSwiftlyRelease() async throws {
let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)
let tmpFileSignature = fs.mktemp(ext: ".sig")
Expand Down Expand Up @@ -82,7 +82,7 @@ import Testing
}
}

@Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws {
@Test(.tags(.large)) func getSwiftlyReleaseMetadataFromSwiftOrg() async throws {
let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())
do {
let currentRelease = try await httpClient.getCurrentSwiftlyRelease()
Expand All @@ -94,6 +94,7 @@ import Testing
}

@Test(
.tags(.large),
arguments:
[PlatformDefinition.macOS, .ubuntu2404, .ubuntu2204, .rhel9, .fedora39, .amazonlinux2, .debian12],
[SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664, .aarch64]
Expand Down
Loading