├── .gitignore ├── .travis.d ├── before-install.sh └── install.sh ├── .travis.yml ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Shell │ ├── EnvironmentTrampoline.swift │ ├── ProcessHelper.swift │ └── Shell.swift └── Tests ├── LinuxMain.swift └── ShellTests ├── ShellTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots/**/*.png 68 | fastlane/test_output 69 | -------------------------------------------------------------------------------- /.travis.d/before-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$TRAVIS_OS_NAME" == "Linux" ]]; then 4 | sudo apt-get install -y wget \ 5 | clang-3.6 libc6-dev make git libicu52 libicu-dev \ 6 | git autoconf libtool pkg-config \ 7 | libblocksruntime-dev \ 8 | libkqueue-dev \ 9 | libpthread-workqueue-dev \ 10 | systemtap-sdt-dev \ 11 | libbsd-dev libbsd0 libbsd0-dbg \ 12 | curl libcurl4-openssl-dev \ 13 | libedit-dev \ 14 | python2.7 python2.7-dev \ 15 | libxml2 16 | 17 | sudo update-alternatives --quiet --install /usr/bin/clang clang /usr/bin/clang-3.6 100 18 | sudo update-alternatives --quiet --install /usr/bin/clang++ clang++ /usr/bin/clang++-3.6 100 19 | fi 20 | -------------------------------------------------------------------------------- /.travis.d/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # our path is: 4 | # /home/travis/build/NozeIO/Noze.io/ 5 | 6 | if ! test -z "$SWIFT_SNAPSHOT_NAME"; then 7 | # Install Swift 8 | wget "${SWIFT_SNAPSHOT_NAME}" 9 | 10 | TARBALL="`ls swift-*.tar.gz`" 11 | echo "Tarball: $TARBALL" 12 | 13 | TARPATH="$PWD/$TARBALL" 14 | 15 | cd $HOME # expand Swift tarball in $HOME 16 | tar zx --strip 1 --file=$TARPATH 17 | pwd 18 | 19 | export PATH="$PWD/usr/bin:$PATH" 20 | which swift 21 | 22 | if [ `which swift` ]; then 23 | echo "Installed Swift: `which swift`" 24 | else 25 | echo "Failed to install Swift?" 26 | exit 42 27 | fi 28 | fi 29 | 30 | swift --version 31 | 32 | 33 | # Environment 34 | 35 | TT_SWIFT_BINARY=`which swift` 36 | 37 | echo "${TT_SWIFT_BINARY}" 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: generic 2 | 3 | notifications: 4 | slack: nozeio:LIFY1Jtkx0FRcLq3u1WliHRZ 5 | 6 | matrix: 7 | include: 8 | - os: Linux 9 | dist: trusty 10 | env: SWIFT_SNAPSHOT_NAME="https://swift.org/builds/swift-5.0-release/ubuntu1404/swift-5.0-RELEASE/swift-5.0-RELEASE-ubuntu14.04.tar.gz" 11 | sudo: required 12 | - os: osx 13 | osx_image: xcode10.2 14 | 15 | before_install: 16 | - ./.travis.d/before-install.sh 17 | 18 | install: 19 | - ./.travis.d/install.sh 20 | 21 | script: 22 | - export PATH="$HOME/usr/bin:$PATH" 23 | - swift build -c release 24 | - swift build -c debug 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Always Right Institute 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Shell", 7 | products: [ 8 | .library(name: "Shell", targets: ["Shell"]), 9 | ], 10 | dependencies: [], 11 | targets: [ 12 | .target (name: "Shell", dependencies: []), 13 | .testTarget(name: "ShellTests", dependencies: ["Shell"]) 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shell 2 | 3 | ![Swift5](https://img.shields.io/badge/swift-5-blue.svg) 4 | ![macOS](https://img.shields.io/badge/os-macOS-green.svg?style=flat) 5 | 6 | Module exposing Unix command line tools as Swift 5 @dynamicCallable functions 7 | 8 | A few words of warning: 9 | This is intended as a demo. 10 | It should work just fine, but in the name of error handling and proper Swift 11 | beauty, 12 | you might want to approach forking processes differently 🤓 13 | (BTW: PRs are welcome!) 14 | 15 | Part of this blog post: 16 | [@dynamicCallable: Unix Tools as Swift Functions](http://www.alwaysrightinstitute.com/swift-dynamic-callable/). 17 | 18 | ## Sample tool 19 | 20 | The regular Swift Package Manager setup process: 21 | 22 | ```shell 23 | mkdir ShellConsumerTest && cd ShellConsumerTest 24 | swift package init --type executable 25 | ``` 26 | 27 | Sample `main.swift`: 28 | ```swift 29 | import Shell 30 | 31 | print(shell.host("zeezide.de")) 32 | ``` 33 | 34 | Sample `Package.swift`: 35 | ```swift 36 | // swift-tools-version:5.0 37 | 38 | import PackageDescription 39 | 40 | let package = Package( 41 | name: "ShellConsumerTest", 42 | dependencies: [ 43 | .package(url: "https://github.com/AlwaysRightInstitute/Shell.git", 44 | from: "0.1.0"), 45 | ], 46 | targets: [ 47 | .target(name: "ShellConsumerTest", dependencies: [ "Shell" ]), 48 | ] 49 | ) 50 | ``` 51 | Remember to add the dependency in two places. WET is best! 52 | 53 | > `swift run` and `swift test` patch the `$PATH` to just `/usr/bin`. You 54 | > may want to run the binary directly to make lookup work properly. 55 | 56 | For this to work, you need to have Swift 5+ installed. 57 | 58 | ## Links 59 | 60 | - [SE-0195 Dynamic Member Lookup](https://github.com/apple/swift-evolution/blob/master/proposals/0195-dynamic-member-lookup.md) 61 | - [SE-0216 Dynamic Callable](https://github.com/apple/swift-evolution/blob/master/proposals/0216-dynamic-callable.md) 62 | - Python [sh module](https://amoffat.github.io/sh/) 63 | 64 | 65 | ## Who 66 | 67 | Brought to you by 68 | [ZeeZide](http://zeezide.de). 69 | We like 70 | [feedback](https://twitter.com/ar_institute), 71 | GitHub stars, 72 | cool [contract work](http://zeezide.com/en/services/services.html), 73 | presumably any form of praise you can think of. 74 | -------------------------------------------------------------------------------- /Sources/Shell/EnvironmentTrampoline.swift: -------------------------------------------------------------------------------- 1 | // Created by Helge Hess on 21.12.18. 2 | // Copyright © 2018 ZeeZide GmbH. All rights reserved. 3 | 4 | import Foundation 5 | 6 | @dynamicMemberLookup 7 | public struct EnvironmentTrampoline { 8 | 9 | public subscript(dynamicMember k: String) -> String? { 10 | return ProcessInfo.processInfo.environment[k] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Shell/ProcessHelper.swift: -------------------------------------------------------------------------------- 1 | // Created by Helge Hess on 21.12.18. 2 | // Copyright © 2018-2020 ZeeZide GmbH. All rights reserved. 3 | 4 | import Foundation 5 | 6 | extension Process { 7 | 8 | public struct FancyResult { 9 | // Not that fancy actually :-) Convenience before everything!!! 10 | 11 | public let status : Int 12 | public let outputData : Data 13 | public let errorData : Data 14 | 15 | public var isSuccess : Bool { return status == 0 } 16 | 17 | public var stdout : String { 18 | return String(data: outputData, encoding: .utf8) ?? "" 19 | } 20 | public var stderr : String { 21 | return String(data: errorData, encoding: .utf8) ?? "" 22 | } 23 | 24 | public func split(separator: Character) -> [ Substring ] { 25 | return stdout.split(separator: separator) 26 | } 27 | 28 | } 29 | 30 | static func launch(at launchPath: String, with arguments: [ String ], 31 | using shell: String? = "/bin/bash") 32 | -> FancyResult 33 | { 34 | let process = Process() 35 | process.launchPath = shell ?? launchPath 36 | process.arguments = shell != nil 37 | ? [ "-c", launchPath + " " + arguments.joined(separator: " ") ] 38 | : arguments 39 | 40 | let stdout = Pipe() 41 | let stderr = Pipe() 42 | process.standardOutput = stdout 43 | process.standardError = stderr 44 | 45 | var outputData = Data() 46 | var errorData = Data() 47 | 48 | let Q = DispatchQueue(label: "shell") 49 | 50 | stdout.fileHandleForReading.readabilityHandler = { handle in 51 | let data = handle.availableData 52 | Q.async { outputData.append(data) } 53 | } 54 | stderr.fileHandleForReading.readabilityHandler = { handle in 55 | let data = handle.availableData 56 | Q.async { errorData.append(data) } 57 | } 58 | 59 | process.launch() 60 | process.waitUntilExit() 61 | 62 | stdout.fileHandleForReading.readabilityHandler = nil 63 | stderr.fileHandleForReading.readabilityHandler = nil 64 | Q.async { 65 | stdout.fileHandleForReading.closeFile() 66 | stderr.fileHandleForReading.closeFile() 67 | } 68 | 69 | return Q.sync { 70 | return FancyResult(status : Int(process.terminationStatus), 71 | outputData : outputData, 72 | errorData : errorData ) 73 | } 74 | } 75 | } 76 | 77 | extension Process.FancyResult : CustomStringConvertible { 78 | 79 | public var description : String { 80 | 81 | func string(for data: Data) -> String { 82 | guard let s = String(data: data, encoding: .utf8) else { 83 | return data.description 84 | } 85 | if s.count > 72 { 86 | return String(s[.. ShellPathTrampoline { 11 | let url = self.url.appendingPathComponent(key) 12 | var isDir : ObjCBool = false 13 | let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) 14 | if exists && isDir.boolValue { 15 | let url = self.url.appendingPathComponent(key, isDirectory: true) 16 | return ShellPathTrampoline(url: url) 17 | } 18 | return ShellPathTrampoline(url: url) 19 | } 20 | 21 | var doesExist : Bool { 22 | return fm.fileExists(atPath: url.path) 23 | } 24 | 25 | var isDirectory : Bool { 26 | var isDir : ObjCBool = false 27 | let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) 28 | return exists && isDir.boolValue 29 | } 30 | 31 | @discardableResult 32 | public func dynamicallyCall(withArguments arguments: [ String ]) 33 | -> Process.FancyResult 34 | { 35 | func makeError(code: Int, info: String) -> Process.FancyResult { 36 | let error = "\(url.path): \(info)".data(using: .utf8) ?? Data() 37 | return Process.FancyResult(status: code, outputData: Data(), 38 | errorData: error) 39 | } 40 | 41 | guard doesExist else { 42 | return makeError(code: 1, info: "No such file or directory") 43 | } 44 | guard !isDirectory else { 45 | return makeError(code: 2, info: "is a directory") 46 | } 47 | guard fm.isExecutableFile(atPath: url.path) else { 48 | return makeError(code: 99, info: "Permission denied") 49 | } 50 | 51 | return Process.launch(at: url.path, with: arguments) 52 | } 53 | } 54 | 55 | @dynamicMemberLookup 56 | public struct ShellTrampoline { 57 | 58 | public let root : ShellPathTrampoline 59 | public var url : URL { return root.url } 60 | 61 | public init(url: URL = URL(fileURLWithPath: "/")) { 62 | self.root = ShellPathTrampoline(url: url) 63 | } 64 | 65 | public let env = EnvironmentTrampoline() 66 | 67 | public subscript(dynamicMember key: String) -> ShellPathTrampoline { 68 | let trampoline = root[dynamicMember: key] 69 | if trampoline.doesExist { return trampoline } 70 | return lookupInPATH(key) ?? trampoline 71 | } 72 | 73 | func lookupInPATH(_ k: String) -> ShellPathTrampoline? { 74 | let searchPath = (env.PATH ?? "/usr/bin").components(separatedBy: ":") 75 | 76 | let testURLs = searchPath.lazy.map { ( path: String ) -> URL in 77 | let testDirURL : URL 78 | if #available(macOS 10.11, *) { 79 | testDirURL = URL(fileURLWithPath: path, relativeTo: self.url) 80 | } 81 | else { 82 | testDirURL = URL(fileURLWithPath: path) 83 | } 84 | return testDirURL.appendingPathComponent(k) 85 | } 86 | 87 | let fm = FileManager.default 88 | for testURL in testURLs { 89 | let testPath = testURL.path 90 | var isDir : ObjCBool = false 91 | 92 | if fm.fileExists(atPath: testPath, isDirectory: &isDir) { 93 | if !isDir.boolValue && fm.isExecutableFile(atPath: testPath) { 94 | return ShellPathTrampoline(url: testURL) 95 | } 96 | } 97 | } 98 | 99 | return nil 100 | } 101 | } 102 | 103 | public let shell = ShellTrampoline() 104 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import ShellTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ShellTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ShellTests/ShellTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Shell 3 | 4 | final class ShellTests: XCTestCase { 5 | 6 | func testSwiftVersionCall() { 7 | let result = shell.swift("--version") 8 | XCTAssert(result.stdout.hasPrefix("Apple Swift")) 9 | XCTAssert(result.status == 0) 10 | } 11 | 12 | func testLSCall() { 13 | // swift runs stuff with PATH having just /usr/bin 14 | let result = shell.bin.ls("/Users/") 15 | XCTAssert(result.status == 0, "unexpected result: \(result)") 16 | XCTAssert(!result.stdout.isEmpty, "unexpected result: \(result)") 17 | for file in shell.ls("/Users/").split(separator: "\n") { 18 | print("dir:", file) 19 | } 20 | } 21 | 22 | func testHostCall() { 23 | let result = shell.host("zeezide.de") 24 | XCTAssert(result.status == 0, "unexpected result: \(result)") 25 | XCTAssert(!result.stdout.isEmpty, "unexpected result: \(result)") 26 | XCTAssert(result.stdout.contains("zeezide.de")) 27 | print(result.stdout) 28 | } 29 | 30 | static var allTests = [ 31 | ("testSwiftVersionCall", testSwiftVersionCall), 32 | ("testLSCall", testLSCall), 33 | ("testHostCall", testHostCall) 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Tests/ShellTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ShellTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------