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
19 changes: 18 additions & 1 deletion .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,24 @@ jobs:
- name: Build (macOS)
run: swift build --build-tests -v
- name: Test (macOS)
run: swift test -v --skip-build --no-parallel
# Xcode 26's Swift 6.2 back-deploys `Span` to macOS 13 / 14 via
# `libswiftCompatibilitySpan.dylib` (under
# `swift-6.2/macosx/`). swift-subprocess references Span types
# unconditionally on `compiler(>=6.2)`, so even with our
# `SubprocessSpan` trait disabled the test binary still has an
# `@rpath/libswiftCompatibilitySpan.dylib` LC_LOAD_DYLIB.
# SwiftPM's test runtime doesn't put the toolchain's
# back-deployment dir on the search path, so we add it
# explicitly via DYLD_LIBRARY_PATH.
run: |
COMPAT_DIR="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx"
if [ ! -d "$COMPAT_DIR" ]; then
echo "::error::expected back-deployment dir not found: $COMPAT_DIR"
ls -d "$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-"*/macosx 2>/dev/null || true
exit 1
fi
export DYLD_LIBRARY_PATH="$COMPAT_DIR:${DYLD_LIBRARY_PATH:-}"
swift test -v --skip-build --no-parallel

build-ios:
# Compile-only check on `generic/platform=iOS`. swift-subprocess is
Expand Down
20 changes: 19 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:6.0
// swift-tools-version:6.1
import PackageDescription

// ShellKit — the virtualized shell-environment abstraction.
Expand Down Expand Up @@ -62,12 +62,33 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser",
from: "1.3.0"),
// Pinned to 0.4.x until 1.0 ships. See issue #1 for context.
// Explicit traits — opt OUT of `SubprocessSpan` because
// ShellKit doesn't use the Span-based overloads, and enabling
// them links a back-deployment shim
// (`libswiftCompatibilitySpan.dylib`) whose @rpath isn't on
// SwiftPM's test runtime search path on macOS 13–15. We do
// keep `SubprocessFoundation` (default-on) because
// ``DefaultProcessLauncher`` reads its captured byte buffers
// through Foundation's `Data`.
.package(url: "https://github.com/swiftlang/swift-subprocess",
.upToNextMinor(from: "0.4.0"),
traits: ["SubprocessFoundation"]),
],
targets: [
.target(
name: "ShellKit",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
// swift-subprocess pins iOS / tvOS / watchOS to "99.0" — kernel
// bans posix_spawn / fork there, so the dep is conditionally
// linked only on platforms where real exec is possible.
// ``DefaultProcessLauncher`` falls back to throwing
// ``ProcessLaunchUnsupportedOnThisPlatform`` on the rest.
.product(name: "Subprocess", package: "swift-subprocess",
condition: .when(platforms: [
.macOS, .linux, .windows, .android,
])),
],
path: "Sources/ShellKit"
),
Expand Down
62 changes: 62 additions & 0 deletions Sources/ShellKit/Process/ChainLauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

/// A composing ``ProcessLauncher`` that consults `primary` first and
/// falls through to `fallback` only when `primary` throws
/// ``ProcessLaunchUnresolved``.
///
/// This is the composition primitive that lets SwiftBash say "consult
/// my builtins, else delegate to real exec":
///
/// ```swift
/// let bashLauncher = BashProcessLauncher(builtins: …) // throws
/// // ProcessLaunchUnresolved
/// // for unknown names
/// shell.processLauncher = ChainLauncher(
/// primary: bashLauncher,
/// fallback: DefaultProcessLauncher())
/// ```
///
/// Other thrown errors (``ProcessLaunchDenied``, transport errors from
/// swift-subprocess, the body of a builtin throwing) propagate
/// unchanged. Only the specific "I don't know about this command"
/// signal is caught.
public struct ChainLauncher: ProcessLauncher {

public let primary: any ProcessLauncher
public let fallback: any ProcessLauncher

public init(primary: any ProcessLauncher, fallback: any ProcessLauncher) {
self.primary = primary
self.fallback = fallback
}

public func launch(
_ executable: Executable,
arguments: Arguments,
environment: Environment,
workingDirectory: String?,
input: InputSource,
output: OutputSink,
error: OutputSink
) async throws -> ExecutionRecord {
do {
return try await primary.launch(
executable,
arguments: arguments,
environment: environment,
workingDirectory: workingDirectory,
input: input,
output: output,
error: error)
} catch is ProcessLaunchUnresolved {
return try await fallback.launch(
executable,
arguments: arguments,
environment: environment,
workingDirectory: workingDirectory,
input: input,
output: output,
error: error)
}
}
}
141 changes: 141 additions & 0 deletions Sources/ShellKit/Process/DefaultProcessLauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Foundation

#if canImport(Subprocess)
import Subprocess
#if canImport(System)
import System
#else
import SystemPackage
#endif
#endif

/// The default ``ProcessLauncher`` shipped with ShellKit. Delegates to
/// swift-subprocess's ``Subprocess.run(_:arguments:environment:workingDirectory:platformOptions:input:output:error:)``
/// with the collected-output overload — captures stdout / stderr into
/// ``Data`` buffers up to the configured limit and forwards each
/// captured chunk to the supplied ``OutputSink``s.
///
/// Installed on ``Shell/processDefault``; embedders that virtualise
/// the process table (SwiftBash) replace it with their own type
/// (typically a ``ChainLauncher`` whose primary stage is a
/// builtin-aware launcher and whose tail is this default).
///
/// ### Platform availability
///
/// swift-subprocess ships only on macOS / Linux / Windows / Android —
/// iOS / tvOS / watchOS / visionOS forbid `posix_spawn` / `fork`. On
/// those platforms ``launch(_:arguments:environment:workingDirectory:input:output:error:)``
/// throws ``ProcessLaunchUnsupportedOnThisPlatform`` and the embedder
/// is expected to install a virtual launcher that goes through
/// ``ProcessTable/spawn(command:body:)`` instead.
///
/// ### v1 limitations
///
/// - Stdin is fully drained into a buffer before exec. Streaming-stdin
/// is a v2 concern.
/// - Stdout / stderr are collected with a fixed
/// ``defaultBufferLimit``-byte cap. Subclass + override or call
/// swift-subprocess's closure-form `run` directly if you need the
/// `AsyncSequence<Data>` shape.
/// - No ``Subprocess.PlatformOptions`` (spawn attributes / file
/// actions). Subclass to inject them.
public struct DefaultProcessLauncher: ProcessLauncher {

/// Maximum bytes captured per stream when the launcher reads
/// stdout / stderr. Overrunning this limit throws an error from
/// swift-subprocess. 4 MiB is enough for almost every script-run
/// while remaining a sane safety stop for runaway producers.
public static let defaultBufferLimit: Int = 4 * 1024 * 1024

public let bufferLimit: Int

public init(bufferLimit: Int = DefaultProcessLauncher.defaultBufferLimit) {
self.bufferLimit = bufferLimit
}

public func launch(
_ executable: Executable,
arguments: Arguments,
environment: Environment,
workingDirectory: String?,
input: InputSource,
output: OutputSink,
error: OutputSink
) async throws -> ExecutionRecord {
#if canImport(Subprocess)
// Drain stdin upfront. v1 doesn't stream — see type doc.
var inputBytes: [UInt8] = []
for await chunk in input.bytes {
inputBytes.append(contentsOf: chunk)
}

let exe: Subprocess.Executable
switch executable.storage {
case .name(let name): exe = .name(name)
case .path(let p): exe = .path(FilePath(p))
}

let args = Subprocess.Arguments(arguments.values)

// Mirror ShellKit's ``Environment/variables`` into a custom
// subprocess environment. `.custom` rather than `.inherit`
// because the embedder is the source of truth — anything that
// should leak through from `ProcessInfo.processInfo.environment`
// is already in `Shell.processDefault.environment.variables`.
// ``Subprocess.Environment.Key.init(_:)`` is package-private;
// the public path is its `ExpressibleByStringLiteral` init.
var envMap: [Subprocess.Environment.Key: String] = [:]
for (k, v) in environment.variables {
envMap[Subprocess.Environment.Key(stringLiteral: k)] = v
}
let subprocessEnv = Subprocess.Environment.custom(envMap)

// Resolve working directory. Explicit override wins; otherwise
// fall back to the shell's `environment.workingDirectory` if
// set; otherwise pass nil so the host's CWD is inherited.
let cwd: FilePath? = {
if let wd = workingDirectory, !wd.isEmpty {
return FilePath(wd)
}
let envCwd = environment.workingDirectory
if !envCwd.isEmpty { return FilePath(envCwd) }
return nil
}()

let record = try await Subprocess.run(
exe,
arguments: args,
environment: subprocessEnv,
workingDirectory: cwd,
input: .array(inputBytes),
output: .bytes(limit: bufferLimit),
error: .bytes(limit: bufferLimit)
)

let stdoutData = Data(record.standardOutput)
let stderrData = Data(record.standardError)

if !stdoutData.isEmpty { output.write(stdoutData) }
if !stderrData.isEmpty { error.write(stderrData) }

let termStatus: TerminationStatus
switch record.terminationStatus {
case .exited(let code):
termStatus = .exited(Int32(code))
#if !os(Windows)
case .signaled(let sig):
termStatus = .signaled(Int32(sig))
#endif
}

return ExecutionRecord(
processIdentifier: Int64(record.processIdentifier.value),
terminationStatus: termStatus,
standardOutput: stdoutData,
standardError: stderrData
)
#else
throw ProcessLaunchUnsupportedOnThisPlatform(executable: executable)
#endif
}
}
65 changes: 65 additions & 0 deletions Sources/ShellKit/Process/Executable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

/// How to locate the program to run when launching a subprocess.
///
/// Mirrors swift-subprocess's ``Subprocess.Executable`` so callers can
/// move between layers without translating types — SwiftScript's
/// `run(...)` bridge re-exports this directly. Lives in ShellKit so
/// consumers don't need a direct swift-subprocess dependency.
///
/// Two forms:
/// - ``name(_:)`` — plain command name (`"git"`, `"echo"`). The
/// launcher resolves it through `PATH` taken from the supplied
/// ``Environment``.
/// - ``path(_:)`` — fully-qualified path (`"/usr/bin/env"`). Used as-is.
public struct Executable: Sendable, Hashable {

public enum Storage: Sendable, Hashable {
case name(String)
case path(String)
}

public let storage: Storage

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

/// Locate the executable by name; the launcher resolves it via
/// `PATH`.
public static func name(_ executableName: String) -> Executable {
Executable(.name(executableName))
}

/// Locate the executable by absolute path.
public static func path(_ filePath: String) -> Executable {
Executable(.path(filePath))
}

/// The string form a user would type — name or path.
public var description: String {
switch storage {
case .name(let n): return n
case .path(let p): return p
}
}
}

/// Argument vector handed to a subprocess. Mirrors
/// swift-subprocess's ``Subprocess.Arguments`` — argv[0] (the program
/// name) is supplied automatically by the launcher; this collection
/// holds argv[1...] only.
public struct Arguments: Sendable, Hashable, ExpressibleByArrayLiteral {

public typealias ArrayLiteralElement = String

public let values: [String]

public init(_ values: [String] = []) {
self.values = values
}

public init(arrayLiteral elements: String...) {
self.values = elements
}
}
Loading
Loading