Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 4 additions & 7 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,13 +192,9 @@ 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).properties(self) {
if key == "UserShell" {
return value
}
}

Expand Down
87 changes: 87 additions & 0 deletions Sources/SwiftlyCore/Commands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation
import SystemPackage

// Directory Service command line utility for macOS
public enum SystemCommand {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: define SystemCommand as an empty enum and build each command as a standalone extension on that enum

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: .init(args),
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: favour using the explicit initializer (Arguments) explicit over using .init

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed

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
}
}
198 changes: 198 additions & 0 deletions Sources/SwiftlyCore/ModeledCommandLine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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) }
}
}

extension Executable {
public func exists() async throws -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

This method doesn't look to be used. I noticed because checking for existence of something often leads to a potential race condition where the thing may disappear in between checking for its existence and its usage. Its often better to just try and use the resource and handle the failure if it doesn't work.

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 is used as the condition on a the medium sized test, so that the test can be skipped. I suppose I could force that check to actually attempt to run a sample command-line, but this seemed better. I'll move this extension over to the SwiftlyTests module since I generally agree with your assessment. It's just a testing concern.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps we could hide this method behind @_spi(Testing) to indicate it is exposed only for test purposes?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, I've moved it over to the testing target.

switch self.storage {
case let .path(p):
return (try await FileSystem.exists(atPath: p))
case let .executable(e):
let path = ProcessInfo.processInfo.environment["PATH"]

guard let path else { return false }

for p in path.split(separator: ":") {
if try await FileSystem.exists(atPath: FilePath(String(p)) / e) {
return true
}
}

return false
}
}
}

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