├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Scripts ├── info.json ├── install.sh └── release-artifactbundle.sh ├── Sources ├── NestCLI │ ├── ArtifactBundleFetcher.swift │ ├── ArtifactDuplicatedDetector.swift │ ├── ExecutableBinaryPreparer.swift │ ├── NestCLIError.swift │ ├── Nestfile.swift │ ├── NestfileController.swift │ └── SwiftPackageBuilder.swift ├── NestKit │ ├── ArtifactBundle │ │ ├── ArtifactBundle.swift │ │ └── ArtifactBundleInfo.swift │ ├── ArtifactBundleAssetSelector.swift │ ├── ArtifactBundleManager.swift │ ├── Assets │ │ ├── AssetRegistryClient.swift │ │ └── AssetRegistryClientBuilder.swift │ ├── ChecksumCalculator.swift │ ├── Configuration.swift │ ├── ExecutableBinary │ │ └── ExecutableBinary.swift │ ├── Extensions │ │ ├── Logger+extension.swift │ │ └── URL+extension.swift │ ├── FileSystem.swift │ ├── Git │ │ ├── GitCommand.swift │ │ ├── GitURL.swift │ │ └── GitVersion.swift │ ├── GitHub │ │ ├── ExcludedTarget.swift │ │ ├── GitHubAssetRegistryClient.swift │ │ ├── GitHubAssetResponse.swift │ │ ├── GitHubRegistryConfigs.swift │ │ ├── GitHubRepositoryName.swift │ │ └── GitHubURLBuilder.swift │ ├── HTTPClient.swift │ ├── NestDirectory.swift │ ├── NestFileDownloader.swift │ ├── NestInfo.swift │ ├── NestInfoController.swift │ ├── Swift │ │ ├── SwiftCommand+Build.swift │ │ ├── SwiftCommand+Checksum.swift │ │ ├── SwiftCommand+Description.swift │ │ ├── SwiftCommand+TargetInfo.swift │ │ ├── SwiftCommand.swift │ │ └── SwiftPackage.swift │ ├── TripleDetector.swift │ └── Utils │ │ ├── ProcessExecutor.swift │ │ └── ProcessExecutorBuilder.swift ├── NestTestHelpers │ ├── FileStorageItem.swift │ ├── MockExecutorBuilder.swift │ ├── MockFileSystem.swift │ └── MockHTTPClient.swift └── nest │ ├── Arguments │ ├── ExcludedTarget+Arguments.swift │ ├── GitURL+Arguemtns.swift │ ├── GitVersion+Arguments.swift │ └── RunCommandArgument.swift │ ├── Commands │ ├── BootstrapCommand.swift │ ├── GenerateNestfileCommand.swift │ ├── InstallCommand.swift │ ├── ListCommand.swift │ ├── ResolveNestfileCommand.swift │ ├── RunCommand.swift │ ├── SwitchCommand.swift │ ├── UninstallCommand.swift │ └── UpdateNestfileCommand.swift │ ├── Utils │ ├── CLIUtil.swift │ ├── Configuration+Dependencies.swift │ └── NestLogHandler.swift │ └── nest.swift └── Tests ├── NestCLITests ├── ArtfactBundleFetcherTests.swift ├── NestfileControllerTests.swift ├── NestfileTests.swift └── Resources │ └── Fixtures │ ├── foo.artifactbundle.zip │ └── without.artifactbundle.folder.artifactbundle.zip ├── NestKitTests ├── ArtifactBundle │ └── ArtifactBundleInfoTests.swift ├── ArtifactBundleManagerTests.swift ├── Extensions │ └── URLTests.swift ├── FileSystemItemTests.swift ├── Git │ ├── GitHubURLBuilderTests.swift │ └── GitURLTests.swift ├── GitHub │ ├── GitHubAssetRegistryClientTests.swift │ ├── GitHubRepositoryNameTests.swift │ └── GitHubServerConfigsTests.swift ├── NestDirectoryTests.swift ├── NestFileDownloaderTests.swift ├── NestInfoControllerTests.swift ├── Swift │ └── SwiftPackageTests.swift ├── TestingEnvironmentVariables.swift └── TripleDetectorTests.swift └── NestTests └── Arguments ├── ExcludedTarget+ArgumentsTests.swift ├── InstallTargetTests.swift └── SubCommandOfRunCommandTests.swift /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | name: Release 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: macos-15 11 | env: 12 | DEVELOPER_DIR: "/Applications/Xcode_16.2.app/Contents/Developer" 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Build Swift package 18 | run: swift build -c release --arch arm64 --arch x86_64 19 | 20 | - name: Get Current Tag 21 | run: echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 22 | 23 | - name: Run shell script with tag 24 | run: ./Scripts/release-artifactbundle.sh "${{ env.TAG_NAME }}" 25 | 26 | - name: Create Release 27 | uses: softprops/action-gh-release@v1 28 | with: 29 | files: | 30 | nest-macos.artifactbundle.zip 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | test: 11 | runs-on: macos-15 12 | env: 13 | DEVELOPER_DIR: "/Applications/Xcode_16.2.app/Contents/Developer" 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Run tests 17 | run: swift test 18 | - name: Release build 19 | run: swift build -c release --arch arm64 --arch x86_64 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .swiftpm/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 matsuji 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.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "3c3e3636d2df56a398272697fc5c57e9f0f5dc0b71b9e6cdf6e704a539d66123", 3 | "pins" : [ 4 | { 5 | "identity" : "rainbow", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/onevcat/Rainbow", 8 | "state" : { 9 | "revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3", 10 | "version" : "4.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-argument-parser", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-argument-parser", 17 | "state" : { 18 | "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", 19 | "version" : "1.5.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-async-operations", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/mtj0928/swift-async-operations", 26 | "state" : { 27 | "revision" : "af209583c5d25f80b3c95b2b7424cc339fa2879d", 28 | "version" : "0.2.2" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-http-types", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-http-types.git", 35 | "state" : { 36 | "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", 37 | "version" : "1.3.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-log", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-log.git", 44 | "state" : { 45 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", 46 | "version" : "1.5.4" 47 | } 48 | }, 49 | { 50 | "identity" : "yams", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/jpsim/Yams.git", 53 | "state" : { 54 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", 55 | "version" : "5.1.3" 56 | } 57 | }, 58 | { 59 | "identity" : "zipfoundation", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/weichsel/ZIPFoundation.git", 62 | "state" : { 63 | "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", 64 | "version" : "0.9.19" 65 | } 66 | } 67 | ], 68 | "version" : 3 69 | } 70 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "nest", 6 | platforms: [.macOS(.v13)], 7 | products: [ 8 | .executable(name: "nest", targets: ["nest"]), 9 | .library(name: "NestCLI", targets: ["NestCLI"]), 10 | .library(name: "NestKit", targets: ["NestKit"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.1"), 14 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 15 | .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), 16 | .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.0"), 17 | .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.1"), 18 | .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.3"), 19 | .package(url: "https://github.com/mtj0928/swift-async-operations", from: "0.2.2"), 20 | ], 21 | targets: [ 22 | .executableTarget(name: "nest", dependencies: [ 23 | "NestCLI", 24 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 25 | ]), 26 | 27 | .target(name: "NestCLI", dependencies: [ 28 | "NestKit", 29 | .product(name: "AsyncOperations", package: "swift-async-operations"), 30 | .product(name: "Yams", package: "Yams") 31 | ]), 32 | .target(name: "NestTestHelpers", dependencies: ["NestKit"]), 33 | .target(name: "NestKit", dependencies: [ 34 | .product(name: "Logging", package: "swift-log"), 35 | "Rainbow", 36 | "ZIPFoundation", 37 | .product(name: "HTTPTypesFoundation", package: "swift-http-types") 38 | ]), 39 | 40 | // MARK: - Test targets 41 | .testTarget(name: "NestTests", dependencies: ["nest"]), 42 | .testTarget( 43 | name: "NestCLITests", 44 | dependencies: ["NestCLI", "NestTestHelpers"], 45 | exclude: ["Resources/Fixtures"] 46 | ), 47 | .testTarget(name: "NestKitTests", dependencies: ["NestKit", "NestTestHelpers"]), 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🪺 nest 2 | 3 | nest is a package manager to install an executable binary which is made with Swift. 4 | 5 | ``` 6 | $ nest install realm/SwiftLint 7 | 📦 Found an artifact bundle, SwiftLintBinary-macos.artifactbundle.zip, for SwiftLint. 8 | 🌐 Downloading the artifact bundle of SwiftLint... 9 | ✅ Success to download the artifact bundle of SwiftLint. 10 | 🪺 Success to install swiftlint. 11 | 12 | $ nest install XcodesOrg/xcodes 13 | 🪹 No artifact bundles in the repository. 14 | 🔄 Cloning xcodes... 15 | 🔨 Building xcodes for 1.4.1... 16 | 🪺 Success to install xcodes. 17 | ``` 18 | 19 | **nest doesn't reach 1.0.0 yet. It may break backward compatibility.** 20 | 21 | ## Concept 22 | nest is highly inspired by [mint](https://github.com/yonaskolb/Mint) and [scipio](https://github.com/giginet/Scipio). 23 | 24 | mint is a tool to install and run executable Swift packages. 25 | The tool is so amazing, but the tool requires to build packages at first. 26 | The build time cannot be ignored on Cl environment where caches are not available like Xcode Cloud. 27 | 28 | scipio is a tool to generate and reuse xcframeworks. 29 | The tool drastically reduced the build time for the pre-build frameworks 30 | by fetching XCFrameworks from remote storage and reusing them. 31 | 32 | nest adopts the concept of these tools and reuses an artifact bundle to reduce the build time. 33 | If there is an artifact bundle in GitHub release, nest downloads the artifact bundles and installs the executable binaries in the bundles. 34 | If not, nest clones and builds the package and installs the executable binaries. 35 | 36 | ## Installation 37 | Run this command. 38 | This script downloads the latest artifact bundle of this repository, and installs nest by using nest in the artifact bundle. 39 | ```sh 40 | curl -s https://raw.githubusercontent.com/mtj0928/nest/main/Scripts/install.sh | bash 41 | ``` 42 | 43 | ## How to Use 44 | 45 | ### Install packages 46 | ```sh 47 | $ nest install realm/SwiftLint 48 | $ nest install realm/SwiftLint 0.55.0 # A version can be specified. 49 | $ nest install https://github.com/realm/SwiftLint 0.55.0 50 | ``` 51 | 52 | ### Uninstall package 53 | ```sh 54 | $ nest uninstall swiftlint # All versions of swiftlint are uninstalled. 55 | $ nest uninstall swiftlint 0.55.0 # A version can be specified. 56 | ``` 57 | 58 | ### Show all binaries 59 | ```sh 60 | $ nest list 61 | ``` 62 | 63 | ### Switch command version 64 | If multiple versions for a command are ionstalled, you can switch the linked version. 65 | ```sh 66 | $ nest switch swiftlint 0.55.0 // swiftlint 0.55.0 are selected. 67 | ``` 68 | 69 | ## Configuration file 70 | `nest` supports to install multiple packages at once with a configuration file which is called nestfile, 71 | and the file needs to be written in YAML. 72 | 73 | `generate-nestfile` command generates the basic nestfile in the current directory. 74 | ```sh 75 | $ nest generate-nestfile 76 | ``` 77 | Then add references to targets. 78 | 79 | ```yaml 80 | nestPath: ./.nest 81 | targets: 82 | # Example 1: Specify a repository 83 | - reference: mtj0928/nest # or htpps://github.com/mtj0928/nest 84 | version: 0.1.0 # (Optional) When a version is not specified, the latest release will be used. 85 | assetName: nest-macos.artifactbundle.zip # (Optional) When a name is not specified, it will be resolved by GitHub API. 86 | checksum: adcc2e3b4d48606cba7787153b0794f8a87e5289803466d63513f04c4d7661fb # (Optional) This is recommended to add it. 87 | # Example 2 Specify zip URL directly 88 | - zipURL: https://github.com/mtj0928/nest/releases/download/0.1.0/nest-macos.artifactbundle.zip 89 | checksum: adcc2e3b4d48606cba7787153b0794f8a87e5289803466d63513f04c4d7661fb # (Optional) This is recommended to add it. 90 | registries: 91 | github: 92 | - host: my-github-enterprise.example.com 93 | tokenEnvironmentVariable: "MY_GHE_TOKEN" 94 | ``` 95 | 96 | Finally run `bootstrap` command. The command installs all artifact bundles in the nestfile at once. 97 | ```sh 98 | $ nest bootstrap nestfile.yaml 99 | ``` 100 | 101 | ### Update nestfile 102 | nest provides two utility commands, `update-nestfile` and `resolve-nestfile`. 103 | 104 | `update-nestfile` command overwrites the nestfile by updating the version and filling in the checksum and the asset name. 105 | 106 | ```sh 107 | $ nest update-nestfile nestfile.yaml 108 | 109 | # Ignore updates of `realm/SwiftLint ` 110 | $ nest update-nestfile nestfile.yaml --excludes realm/SwiftLint 111 | 112 | # Ignore versions of 0.58.1 and 0.58.2 of `realm/SwiftLint ` 113 | $ nest update-nestfile nestfile.yaml --excludes realm/SwiftLint@0.58.1 realm/SwiftLint@0.58.2 114 | ``` 115 | 116 | `resolve-nestfile` is a similar command but it doesn't update the version when one is specified. 117 | 118 | ### Execute the binary that matches `owner/repository` written in nestfile 119 | 120 | ```sh 121 | $ nest run owner/repository 122 | ``` 123 | 124 | If a version matching the nestfile is not installed, it will attempt to install and run the matching version. 125 | 126 | ## Cache directory 127 | `nest` stores artifacts at `~/.nest` as a default. 128 | If you want change the directory, 129 | please update `$NEST_PATH` or specify `nestPath` in a configuration file (only `bootstrap`). 130 | 131 | ## Use GitHub API token to fetch Artifact Bundles 132 | 133 | Fetching releases sometimes fails due to API limit, so we recommended to pass a GitHub API token. 134 | 135 | ### Use `GH_TOKEN` environment variable 136 | 137 | The simplest way is the passing `GH_TOKEN` or `GHE_TOKEN` environment variable. nest uses the token for GitHub.com or any other GitHub Enterprise servers. 138 | 139 | ### Use `registries` in nestfile 140 | 141 | If you want to use a token for GitHub Enterprise, you can specify the names of environment variables in the `registries` section in the nestfile. 142 | 143 | nest will automatically resolve the environment variables to fetch from each server. 144 | 145 | ```yaml 146 | registries: 147 | github: 148 | - host: github.com 149 | tokenEnvironmentVariable: "MY_GH_TOKEN" 150 | - host: my-github-enterprise.example.com 151 | tokenEnvironmentVariable: "MY_GHE_TOKEN" 152 | ``` 153 | 154 | If the value is not set, uses `GH_TOKEN` instead if available. 155 | 156 | ## Why is the name `nest`? 157 | A nest is place where Swift birds store their crafts🪺 158 | -------------------------------------------------------------------------------- /Scripts/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0", 3 | "artifacts": { 4 | "nest": { 5 | "version": "__VERSION__", 6 | "type": "executable", 7 | "variants": [ 8 | { 9 | "path": "nest-__VERSION__-macos/bin/nest", 10 | "supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"] 11 | } 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REPO="mtj0928/nest" 4 | ASSET_NAME="nest-macos.artifactbundle.zip" 5 | ASSET_URL="https://github.com/$REPO/releases/latest/download/$ASSET_NAME" 6 | 7 | # Download zip file 8 | curl -sL -o $ASSET_NAME $ASSET_URL 9 | unzip -qo $ASSET_NAME -d extracted_files 10 | rm $ASSET_NAME 11 | 12 | VERSION=$(ls ./extracted_files/nest.artifactbundle | sed -n 's/^nest-\([^-]*\)-macos$/\1/p' | head -n 1) 13 | if [ -z "$VERSION" ]; then 14 | echo "Version not found in the directory." 15 | exit 1 16 | fi 17 | 18 | ./extracted_files/nest.artifactbundle/nest-$VERSION-macos/bin/nest install mtj0928/nest > /dev/null 19 | rm -rf extracted_files 20 | echo "🪺 nest was installed at ~/.nest/bin" 21 | echo "🪺 Please add it to \$PATH" 22 | -------------------------------------------------------------------------------- /Scripts/release-artifactbundle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION_STRING="$1" 4 | 5 | mkdir -p nest.artifactbundle/nest-$VERSION_STRING-macos/bin 6 | 7 | sed "s/__VERSION__/$VERSION_STRING/g" ./Scripts/info.json > "nest.artifactbundle/info.json" 8 | 9 | cp -f "./.build/apple/Products/Release/nest" "nest.artifactbundle/nest-$VERSION_STRING-macos/bin" 10 | 11 | zip -yr - "nest.artifactbundle" > "./nest-macos.artifactbundle.zip" 12 | -------------------------------------------------------------------------------- /Sources/NestCLI/ArtifactDuplicatedDetector.swift: -------------------------------------------------------------------------------- 1 | import NestKit 2 | import Foundation 3 | 4 | public enum ArtifactDuplicatedDetector { 5 | 6 | public static func isAlreadyInstalled(url: GitURL, version: String?, in nestInfo: NestInfo) -> Bool { 7 | nestInfo.commands.values 8 | .flatMap { $0 } 9 | .contains(where: { command in 10 | guard version == command.version else { return false } 11 | 12 | switch command.manufacturer { 13 | case .artifactBundle(let sourceInfo): 14 | if let repository = sourceInfo.repository { 15 | return repository.reference == url 16 | } 17 | return false 18 | case .localBuild(let repository): 19 | return repository.reference == url 20 | } 21 | }) 22 | } 23 | 24 | public static func isAlreadyInstalled(zipURL: URL, in nestInfo: NestInfo) -> Bool { 25 | nestInfo.commands.values 26 | .flatMap { $0 } 27 | .contains(where: { command in 28 | switch command.manufacturer { 29 | case .artifactBundle(let sourceInfo): 30 | return sourceInfo.zipURL == zipURL 31 | case .localBuild: 32 | return false 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/NestCLI/ExecutableBinaryPreparer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NestKit 3 | import Logging 4 | 5 | public struct ExecutableBinaryPreparer { 6 | private let artifactBundleFetcher: ArtifactBundleFetcher 7 | private let swiftPackageBuilder: SwiftPackageBuilder 8 | private let nestInfoController: NestInfoController 9 | private let artifactBundleManager: ArtifactBundleManager 10 | private let logger: Logger 11 | 12 | public init( 13 | artifactBundleFetcher: ArtifactBundleFetcher, 14 | swiftPackageBuilder: SwiftPackageBuilder, 15 | nestInfoController: NestInfoController, 16 | artifactBundleManager: ArtifactBundleManager, 17 | logger: Logger 18 | ) { 19 | self.artifactBundleFetcher = artifactBundleFetcher 20 | self.swiftPackageBuilder = swiftPackageBuilder 21 | self.nestInfoController = nestInfoController 22 | self.artifactBundleManager = artifactBundleManager 23 | self.logger = logger 24 | } 25 | 26 | public func resolveInstalledExecutableBinariesFromNestInfo(for gitURL: GitURL, version: GitVersion) -> [ExecutableBinary] { 27 | let commands = nestInfoController.getInfo().commands 28 | .compactMapValues { commands -> [NestInfo.Command]? in 29 | let filteredCommands = commands.filter { command in 30 | command.repository?.reference == gitURL && command.version == version.description 31 | } 32 | return filteredCommands.isEmpty ? nil : filteredCommands 33 | } 34 | return commands 35 | .flatMap { commandName, commands in commands.map { (commandName, $0) }} 36 | .map { commandName, command in 37 | ExecutableBinary( 38 | commandName: commandName, 39 | binaryPath: URL(filePath: command.binaryPath), 40 | version: command.version, 41 | manufacturer: command.manufacturer 42 | ) 43 | } 44 | } 45 | 46 | /// Installs binaries in the given repository. 47 | /// - Parameters: 48 | /// - gitURL: A git repository which should be installed. 49 | /// - version: A version of the repository 50 | /// - assetName: An asset name of an artifact bundle if it is known. `nil` can be accepted but additional API requests are required in that case. 51 | /// - checksumOption: A checksum option. 52 | public func installBinaries( 53 | gitURL: GitURL, 54 | version: GitVersion, 55 | assetName: String?, 56 | checksumOption: ChecksumOption 57 | ) async throws { 58 | let executableBinaries = try await fetchOrBuildBinariesFromGitRepository( 59 | at: gitURL, 60 | version: version, 61 | artifactBundleZipFileName: assetName, 62 | checksum: checksumOption 63 | ) 64 | 65 | for binary in executableBinaries { 66 | try artifactBundleManager.install(binary) 67 | logger.info("🪺 Success to install \(binary.commandName) version \(binary.version).") 68 | } 69 | } 70 | 71 | public func fetchOrBuildBinariesFromGitRepository( 72 | at gitURL: GitURL, 73 | version: GitVersion, 74 | artifactBundleZipFileName: String?, 75 | checksum: ChecksumOption 76 | ) async throws -> [ExecutableBinary] { 77 | switch gitURL { 78 | case .url(let url): 79 | do { 80 | return try await artifactBundleFetcher.fetchArtifactBundleFromGitRepository( 81 | for: url, 82 | version: version, 83 | artifactBundleZipFileName: artifactBundleZipFileName, 84 | checksum: checksum 85 | ) 86 | } catch ArtifactBundleFetcherError.noCandidates { 87 | logger.info("🪹 No artifact bundles in the repository.") 88 | } catch ArtifactBundleFetcherError.unsupportedTriple { 89 | logger.info("🪹 No binaries corresponding to the current triple.") 90 | } catch AssetRegistryClientError.notFound { 91 | logger.info("🪹 No releases in the repository.") 92 | } catch NestCLIError.alreadyInstalled { 93 | logger.info("🪺 The artifact bundle has been already installed.") 94 | return [] 95 | } catch { 96 | logger.error(error) 97 | } 98 | case .ssh: 99 | logger.info("Specify a https url if you want to download an artifact bundle.") 100 | } 101 | 102 | do { 103 | return try await swiftPackageBuilder.build(gitURL: gitURL, version: version) 104 | } catch NestCLIError.alreadyInstalled { 105 | logger.info("🪺 The artifact bundle has been already installed.") 106 | return [] 107 | } 108 | } 109 | 110 | public func fetchArtifactBundle(at url: URL, checksum: ChecksumOption) async throws -> [ExecutableBinary] { 111 | do { 112 | return try await artifactBundleFetcher.downloadArtifactBundle(url: url, checksum: checksum) 113 | } catch NestCLIError.alreadyInstalled { 114 | logger.info("🪺 The artifact bundle has been already installed.") 115 | return [] 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/NestCLI/NestCLIError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum NestCLIError: LocalizedError { 4 | case alreadyInstalled 5 | } 6 | -------------------------------------------------------------------------------- /Sources/NestCLI/Nestfile.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NestKit 3 | import Yams 4 | 5 | public struct Nestfile: Codable, Sendable { 6 | public var nestPath: String? 7 | public var targets: [Target] 8 | public var registries: RegistryConfigs? 9 | 10 | public init(nestPath: String?, targets: [Target]) { 11 | self.nestPath = nestPath 12 | self.targets = targets 13 | } 14 | 15 | public enum Target: Codable, Equatable, Sendable { 16 | case repository(Repository) 17 | case deprecatedZIP(DeprecatedZIPURL) 18 | case zip(ZIPURL) 19 | 20 | public init(from decoder: any Decoder) throws { 21 | let container = try decoder.singleValueContainer() 22 | if let repository = try? container.decode(Repository.self) { 23 | self = .repository(repository) 24 | } else if let zipURL = try? container.decode(ZIPURL.self) { 25 | self = .zip(zipURL) 26 | } else if let zipURL = try? container.decode(DeprecatedZIPURL.self) { 27 | self = .deprecatedZIP(zipURL) 28 | } else { 29 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected repository or zip URL") 30 | } 31 | } 32 | 33 | public func encode(to encoder: any Encoder) throws { 34 | var container = encoder.singleValueContainer() 35 | switch self { 36 | case .repository(let repository): 37 | try container.encode(repository) 38 | case .deprecatedZIP(let deprecatedZIPURL): 39 | try container.encode(deprecatedZIPURL) 40 | case .zip(let zipURL): 41 | try container.encode(zipURL) 42 | } 43 | } 44 | 45 | public var isDeprecatedZIP: Bool { 46 | switch self { 47 | case .deprecatedZIP: return true 48 | default: return false 49 | } 50 | } 51 | 52 | public var version: String? { 53 | switch self { 54 | case let .repository(repository): 55 | return repository.version 56 | case .zip, .deprecatedZIP: 57 | return nil 58 | } 59 | } 60 | 61 | public var assetName: String? { 62 | switch self { 63 | case .repository(let repository): repository.assetName 64 | case .zip, .deprecatedZIP: nil 65 | } 66 | } 67 | 68 | public var checksum: String? { 69 | switch self { 70 | case .repository(let repository): repository.checksum 71 | case .zip(let zipURL): zipURL.checksum 72 | case .deprecatedZIP: nil 73 | } 74 | } 75 | } 76 | 77 | public struct Repository: Codable, Equatable, Sendable { 78 | /// A reference to a repository. 79 | /// 80 | /// The acceptable formats are the followings 81 | /// - `{owner}/{name}` 82 | /// - HTTPS URL 83 | /// - SSH URL. 84 | public var reference: String 85 | public var version: String? 86 | 87 | /// Specify an asset file name of an artifact bundle. 88 | /// If the name is not specified, the tool fetch the name by GitHub API. 89 | public var assetName: String? 90 | public var checksum: String? 91 | 92 | public init(reference: String, version: String?, assetName: String?, checksum: String?) { 93 | self.reference = reference 94 | self.version = version 95 | self.assetName = assetName 96 | self.checksum = checksum 97 | } 98 | } 99 | 100 | public struct DeprecatedZIPURL: Codable, Equatable, Sendable { 101 | public var url: String 102 | 103 | public init(url: String) { 104 | self.url = url 105 | } 106 | 107 | public init(from decoder: any Decoder) throws { 108 | let container = try decoder.singleValueContainer() 109 | self.url = try container.decode(String.self) 110 | } 111 | 112 | public func encode(to encoder: Encoder) throws { 113 | var container = encoder.singleValueContainer() 114 | try container.encode(url) 115 | } 116 | } 117 | 118 | public struct ZIPURL: Codable, Equatable, Sendable { 119 | public var zipURL: String 120 | public var checksum: String? 121 | 122 | public init(zipURL: String, checksum: String?) { 123 | self.zipURL = zipURL 124 | self.checksum = checksum 125 | } 126 | 127 | enum CodingKeys: String, CodingKey { 128 | case zipURL = "zipURL" 129 | case checksum 130 | } 131 | } 132 | 133 | public struct RegistryConfigs: Codable, Sendable { 134 | public var github: [GitHubInfo] 135 | 136 | public struct GitHubInfo: Codable, Sendable { 137 | public var host: String 138 | public var tokenEnvironmentVariable: String 139 | 140 | public init(host: String, tokenEnvironmentVariable: String) { 141 | self.host = host 142 | self.tokenEnvironmentVariable = tokenEnvironmentVariable 143 | } 144 | } 145 | 146 | public init(github: [GitHubInfo]) { 147 | self.github = github 148 | } 149 | } 150 | } 151 | 152 | extension Nestfile { 153 | public func write(to path: String, fileSystem: some FileSystem) throws { 154 | let url = URL(fileURLWithPath: path) 155 | let data = try YAMLEncoder().encode(self) 156 | try fileSystem.write(data.data(using: .utf8)!, to: url) 157 | } 158 | 159 | public static func load(from path: String, fileSystem: some FileSystem) throws -> Nestfile { 160 | let url = URL(fileURLWithPath: path) 161 | let data = try fileSystem.data(at: url) 162 | return try YAMLDecoder().decode(Nestfile.self, from: data) 163 | } 164 | } 165 | 166 | extension Nestfile.RegistryConfigs { 167 | public typealias GitHubHost = String 168 | public var githubServerTokenEnvironmentVariableNames: [GitHubHost: String] { 169 | github.reduce(into: [:]) { $0[$1.host] = $1.tokenEnvironmentVariable } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/NestCLI/NestfileController.swift: -------------------------------------------------------------------------------- 1 | import AsyncOperations 2 | import Foundation 3 | import NestKit 4 | 5 | public struct NestfileController: Sendable { 6 | private let assetRegistryClientBuilder: AssetRegistryClientBuilder 7 | private let fileSystem: any FileSystem 8 | private let fileDownloader: any FileDownloader 9 | private let checksumCalculator: any ChecksumCalculator 10 | 11 | public init( 12 | assetRegistryClientBuilder: AssetRegistryClientBuilder, 13 | fileSystem: some FileSystem, 14 | fileDownloader: some FileDownloader, 15 | checksumCalculator: some ChecksumCalculator 16 | ) { 17 | self.assetRegistryClientBuilder = assetRegistryClientBuilder 18 | self.fileSystem = fileSystem 19 | self.fileDownloader = fileDownloader 20 | self.checksumCalculator = checksumCalculator 21 | } 22 | 23 | /// Get the version that matches the `owner/repo` 24 | /// - Parameters: 25 | /// - gitURL: A git URL. 26 | /// - nestfile: Nestfile struct that defines nestfile.yaml 27 | public func target(matchingTo gitURL: GitURL, in nestfile: Nestfile) -> Nestfile.Target? { 28 | return nestfile.targets 29 | .first { target in 30 | guard case let .repository(repository) = target else { return false } 31 | return GitURL.parse(from: repository.reference) == gitURL 32 | } 33 | } 34 | 35 | public func update(_ nestfile: Nestfile, excludedTargets: [ExcludedTarget]) async throws -> Nestfile { 36 | var nestfile = nestfile 37 | nestfile.targets = try await nestfile.targets.asyncMap(numberOfConcurrentTasks: .max) { target in 38 | try await updateTarget(target, versionResolution: .update, excludedTargets: excludedTargets) 39 | } 40 | return nestfile 41 | } 42 | 43 | public func resolve(_ nestfile: Nestfile) async throws -> Nestfile { 44 | var nestfile = nestfile 45 | nestfile.targets = try await nestfile.targets.asyncMap(numberOfConcurrentTasks: .max) { target in 46 | try await updateTarget(target, versionResolution: .specific, excludedTargets: []) 47 | } 48 | return nestfile 49 | } 50 | 51 | private func updateTarget( 52 | _ target: Nestfile.Target, 53 | versionResolution: VersionResolution, 54 | excludedTargets: [ExcludedTarget] 55 | ) async throws -> Nestfile.Target { 56 | switch target { 57 | case .repository(let repository): 58 | let newRepository = try await updateRepository( 59 | repository, 60 | versionResolution: versionResolution, 61 | excludedTargets: excludedTargets 62 | ) 63 | return .repository(newRepository) 64 | case .zip(let zipURL): 65 | guard let url = URL(string: zipURL.zipURL) else { return target } 66 | let newZipURL = try await updateZip(url: url) 67 | return .zip(newZipURL) 68 | case .deprecatedZIP(let zipURL): 69 | guard let url = URL(string: zipURL.url) else { 70 | return .zip(Nestfile.ZIPURL(zipURL: zipURL.url, checksum: nil)) 71 | } 72 | let newZipURL = try await updateZip(url: url) 73 | return .zip(newZipURL) 74 | } 75 | } 76 | 77 | private func updateRepository( 78 | _ repository: Nestfile.Repository, 79 | versionResolution: VersionResolution, 80 | excludedTargets: [ExcludedTarget] 81 | ) async throws -> Nestfile.Repository { 82 | let excludedTargetsMatchingReference = excludedTargets 83 | .filter { $0.reference == repository.reference } 84 | guard excludedTargetsMatchingReference.filter({ $0.version == nil }).isEmpty else { 85 | return repository 86 | } 87 | 88 | guard let gitURL = GitURL.parse(from: repository.reference), 89 | case .url(let url) = gitURL 90 | else { return repository } 91 | 92 | let assetRegistryClient = assetRegistryClientBuilder.build(for: url) 93 | let version = resolveVersion(repository: repository, resolution: versionResolution) 94 | let assetInfo = switch (version, excludedTargetsMatchingReference.isEmpty) { 95 | case (.latestRelease, true), (.tag, _): 96 | try await assetRegistryClient.fetchAssets(repositoryURL: url, version: version) 97 | case (.latestRelease, false): 98 | try await assetRegistryClient.fetchAssetsApplyingExcludedTargets( 99 | repositoryURL: url, 100 | version: version, 101 | excludingTargets: excludedTargetsMatchingReference.compactMap { $0.version } 102 | ) 103 | } 104 | 105 | let selector = ArtifactBundleAssetSelector() 106 | guard let selectedAsset = selector.selectArtifactBundle(from: assetInfo.assets, fileName: repository.assetName) else { 107 | return Nestfile.Repository( 108 | reference: repository.reference, 109 | version: assetInfo.tagName, 110 | assetName: nil, 111 | checksum: nil 112 | ) 113 | } 114 | 115 | if !selectedAsset.url.needsUnzip { 116 | return Nestfile.Repository( 117 | reference: repository.reference, 118 | version: assetInfo.tagName, 119 | assetName: selectedAsset.fileName, 120 | checksum: nil 121 | ) 122 | } 123 | 124 | let checksum = try await downloadZIP(url: selectedAsset.url) 125 | return Nestfile.Repository( 126 | reference: repository.reference, 127 | version: assetInfo.tagName, 128 | assetName: selectedAsset.fileName, 129 | checksum: checksum 130 | ) 131 | } 132 | 133 | private func updateZip(url: URL) async throws -> Nestfile.ZIPURL { 134 | let checksum = try await downloadZIP(url: url) 135 | return Nestfile.ZIPURL(zipURL: url.absoluteString, checksum: checksum) 136 | } 137 | 138 | private func downloadZIP(url: URL) async throws -> String? { 139 | let downloadedFilePath = try await fileDownloader.download(url: url) 140 | let downloadedZipFilePath = fileSystem.temporaryDirectory.appendingPathComponent(url.lastPathComponent) 141 | try fileSystem.removeItemIfExists(at: downloadedZipFilePath) 142 | try fileSystem.copyItem(at: downloadedFilePath, to: downloadedZipFilePath) 143 | 144 | let checksum = try await checksumCalculator.calculate(downloadedZipFilePath.path()) 145 | return checksum 146 | } 147 | 148 | private func resolveVersion(repository: Nestfile.Repository, resolution: VersionResolution) -> GitVersion { 149 | switch resolution { 150 | case .update: return .latestRelease 151 | case .specific: 152 | if let repositoryVersion = repository.version { 153 | return .tag(repositoryVersion) 154 | } else { 155 | return .latestRelease 156 | } 157 | } 158 | } 159 | } 160 | 161 | private enum VersionResolution { 162 | /// A case indicating using the latest version for all repositories. 163 | case update 164 | 165 | /// A case indicating using the latest version for repository whose version is not specified. 166 | /// If a version is specified, the versions is used. 167 | case specific 168 | } 169 | -------------------------------------------------------------------------------- /Sources/NestCLI/SwiftPackageBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import NestKit 4 | 5 | public struct SwiftPackageBuilder { 6 | private let workingDirectory: URL 7 | private let executorBuilder: any ProcessExecutorBuilder 8 | private let fileSystem: any FileSystem 9 | private let nestInfoController: NestInfoController 10 | private let assetRegistryClientBuilder: AssetRegistryClientBuilder 11 | private let logger: Logger 12 | 13 | public init( 14 | workingDirectory: URL, 15 | executorBuilder: any ProcessExecutorBuilder, 16 | fileSystem: some FileSystem, 17 | nestInfoController: NestInfoController, 18 | assetRegistryClientBuilder: AssetRegistryClientBuilder, 19 | logger: Logger 20 | ) { 21 | self.workingDirectory = workingDirectory 22 | self.executorBuilder = executorBuilder 23 | self.fileSystem = fileSystem 24 | self.nestInfoController = nestInfoController 25 | self.assetRegistryClientBuilder = assetRegistryClientBuilder 26 | self.logger = logger 27 | } 28 | 29 | public func build(gitURL: GitURL, version: GitVersion) async throws -> [ExecutableBinary] { 30 | // Reset the existing directory. 31 | let repositoryDirectory = workingDirectory.appending(component: gitURL.repositoryName) 32 | try fileSystem.removeItemIfExists(at: repositoryDirectory) 33 | 34 | // Resolve a tag or version. 35 | let tagOrVersion = try await resolveTagOrVersion(gitURL: gitURL, version: version) 36 | logger.debug("The tag or version is \(tagOrVersion ?? "nil")") 37 | 38 | let info = nestInfoController.getInfo() 39 | if ArtifactDuplicatedDetector.isAlreadyInstalled(url: gitURL, version: tagOrVersion, in: info) { 40 | throw NestCLIError.alreadyInstalled 41 | } 42 | 43 | // Clone the repository. 44 | logger.info("🔄 Cloning \(gitURL.repositoryName)...") 45 | try await GitCommand(executor: executorBuilder.build()).clone( 46 | repositoryURL: gitURL, 47 | tag: tagOrVersion, 48 | to: repositoryDirectory 49 | ) 50 | 51 | // Get the current branch. 52 | let branch = try await GitCommand( 53 | executor: executorBuilder.build(currentDirectory: repositoryDirectory) 54 | ).currentBranch() 55 | 56 | // Build the repository 57 | logger.info("🔨 Building \(gitURL.repositoryName) for \(tagOrVersion ?? branch)...") 58 | let swiftPackage = SwiftPackage(at: repositoryDirectory, executorBuilder: executorBuilder) 59 | try await swiftPackage.buildForRelease() 60 | 61 | // Extract the built binaries. 62 | let executableNames = try await swiftPackage.description().executableNames 63 | return executableNames.map { executableName in 64 | let version = tagOrVersion ?? branch 65 | return ExecutableBinary( 66 | commandName: executableName, 67 | binaryPath: swiftPackage.executableFile(name: executableName), 68 | version: version, 69 | manufacturer: .localBuild(repository: Repository(reference: gitURL, version: version)) 70 | ) 71 | } 72 | } 73 | 74 | private func resolveTagOrVersion(gitURL: GitURL, version: GitVersion) async throws -> String? { 75 | switch (gitURL, version) { 76 | case (.url(let url), .latestRelease): 77 | let assetRegistryClient = assetRegistryClientBuilder.build(for: url) 78 | return try? await assetRegistryClient.fetchAssets(repositoryURL: url, version: .latestRelease).tagName 79 | 80 | case (.ssh, .latestRelease): 81 | return nil 82 | 83 | case (_, .tag(let tagName)): 84 | return tagName 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/NestKit/ArtifactBundle/ArtifactBundle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A data model representing artifact bundle. 4 | public struct ArtifactBundle: Sendable { 5 | /// A data of info.json in artifact bundle. 6 | public let info: ArtifactBundleInfo 7 | 8 | /// A directory where the artifact bundle is located 9 | public let rootDirectory: URL 10 | 11 | /// A information where the artifact bundle is from. 12 | public let sourceInfo: ArtifactBundleSourceInfo 13 | 14 | public init(info: ArtifactBundleInfo, rootDirectory: URL, sourceInfo: ArtifactBundleSourceInfo) { 15 | self.info = info 16 | self.rootDirectory = rootDirectory 17 | self.sourceInfo = sourceInfo 18 | } 19 | } 20 | 21 | extension ArtifactBundle { 22 | public static func load( 23 | at path: URL, 24 | sourceInfo: ArtifactBundleSourceInfo, 25 | fileSystem: some FileSystem 26 | ) throws -> ArtifactBundle { 27 | let infoPath = path.appending(path: "info.json") 28 | let data = try fileSystem.data(at: infoPath) 29 | let info = try JSONDecoder().decode(ArtifactBundleInfo.self, from: data) 30 | 31 | return ArtifactBundle(info: info, rootDirectory: path, sourceInfo: sourceInfo) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/NestKit/ArtifactBundle/ArtifactBundleInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A data structure corresponding to info.json in artifact bundle. 4 | /// 5 | ///[Definition in Swift evolution]( https://github.com/swiftlang/swift-evolution/blob/main/proposals/0305-swiftpm-binary-target-improvements.md#artifact-bundle-manifest) 6 | public struct ArtifactBundleInfo: Codable, Hashable, Sendable { 7 | public var schemaVersion: String 8 | /// A map of artifacts. The key is an identifier of an artifact. 9 | /// In most cases, the identifier is the same command name. 10 | public var artifacts: [String: Artifact] 11 | 12 | public init(schemaVersion: String, artifacts: [String : Artifact]) { 13 | self.schemaVersion = schemaVersion 14 | self.artifacts = artifacts 15 | } 16 | } 17 | 18 | public struct Artifact: Codable, Hashable, Sendable { 19 | /// A version of the artifact 20 | public var version: String 21 | 22 | /// A type of the artifact. 23 | /// Current only "executable" is passed. 24 | public var type: String 25 | 26 | // Variants of the artifact. 27 | public var variants: Set 28 | 29 | public init(version: String, type: String, variants: Set) { 30 | self.version = version 31 | self.type = type 32 | self.variants = variants 33 | } 34 | } 35 | 36 | /// A data structure representing an executable in an artifact bundle. 37 | public struct ArtifactVariant: Codable, Hashable, Sendable { 38 | /// A path to the executable file 39 | public var path: String 40 | 41 | /// A tripes which the executable supports 42 | public var supportedTriples: [String] 43 | 44 | public init(path: String, supportedTriples: [String]) { 45 | self.path = path 46 | self.supportedTriples = supportedTriples 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/NestKit/ArtifactBundleAssetSelector.swift: -------------------------------------------------------------------------------- 1 | public struct ArtifactBundleAssetSelector { 2 | public init() {} 3 | 4 | /// Select an artifact bundle from the given GitHun assets. 5 | /// If there is no proper asset, the function returns `nil`. 6 | public func selectArtifactBundle(from assets: [Asset], fileName: String?) -> Asset? { 7 | assets.first(where: { $0.fileName == fileName }) 8 | ?? assets.first(where: { $0.fileName.contains("artifactbundle") }) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/NestKit/ArtifactBundleManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ArtifactBundleManager: Sendable { 4 | private let fileSystem: any FileSystem 5 | private let directory: NestDirectory 6 | 7 | public init(fileSystem: some FileSystem, directory: NestDirectory) { 8 | self.fileSystem = fileSystem 9 | self.directory = directory 10 | } 11 | 12 | /// Puts the binary to artifact bundle directory and make a symbolic link from the put binary to bin directory. 13 | public func install(_ binary: ExecutableBinary) throws { 14 | let binaryDirectory = directory.binaryDirectory(of: binary) 15 | try fileSystem.createDirectory(at: binaryDirectory, withIntermediateDirectories: true) 16 | 17 | try add(binary) 18 | try link(binary) 19 | } 20 | 21 | public func uninstall(command name: String, version: String) throws { 22 | let info = nestInfoController.getInfo() 23 | guard var commands = info.commands[name] else { return } 24 | 25 | commands = commands.filter { $0.version == version } 26 | 27 | for command in commands { 28 | // Remove symbolic link 29 | if let linkedFilePath = try? self.linkedFilePath(commandName: name), 30 | linkedFilePath == command.binaryPath { 31 | let resourceNames = command.resourcePaths.map { directory.url($0).lastPathComponent } 32 | for target in resourceNames + [name] { 33 | let symbolicFilePath = directory.symbolicPath(name: target) 34 | try? fileSystem.removeItem(at: symbolicFilePath) 35 | } 36 | } 37 | 38 | // Remove files 39 | let binaryPath = URL(filePath: directory.rootDirectory.path() + command.binaryPath) 40 | try? fileSystem.removeItemIfExists(at: binaryPath) 41 | 42 | // Remove empty directories 43 | try fileSystem.removeEmptyDirectory( 44 | from: binaryPath.deletingLastPathComponent(), 45 | until: directory.rootDirectory 46 | ) 47 | } 48 | try nestInfoController.remove(command: name, version: version) 49 | } 50 | 51 | public func list() -> [String: [NestInfo.Command]] { 52 | nestInfoController.getInfo().commands 53 | } 54 | 55 | private func add(_ binary: ExecutableBinary) throws { 56 | // Copy binary 57 | let binaryPath = directory.binaryPath(of: binary) 58 | try fileSystem.removeItemIfExists(at: binaryPath) 59 | try fileSystem.copyItem(at: binary.binaryPath, to: binaryPath) 60 | 61 | // Copy resources 62 | let resources = try resources(of: binary) 63 | var copiedResources: [URL] = [] 64 | for resource in resources { 65 | let destination = directory.binaryDirectory(of: binary).appending(path: resource.lastPathComponent) 66 | try fileSystem.removeItemIfExists(at: destination) 67 | try fileSystem.copyItem(at: resource, to: destination) 68 | copiedResources.append(destination) 69 | } 70 | 71 | let command = NestInfo.Command( 72 | version: binary.version, 73 | binaryPath: directory.relativePath(binaryPath), 74 | resourcePaths: copiedResources.map { directory.relativePath($0) }, 75 | manufacturer: binary.manufacturer 76 | ) 77 | try nestInfoController.add(name: binary.commandName, command: command) 78 | } 79 | 80 | /// Makes a symbolic link for the given binary to bin directory. 81 | public func link(_ binary: ExecutableBinary) throws { 82 | try fileSystem.createDirectory(at: directory.bin, withIntermediateDirectories: true) 83 | 84 | // Check existing resources are not conflicted. 85 | let conflictingInfo = try extractConflictInfos(binary: binary) 86 | if !conflictingInfo.isEmpty { 87 | throw ArtifactBundleManagerError.resourceConflicting( 88 | commandName: binary.commandName, 89 | conflictingNames: conflictingInfo.map(\.commandName), 90 | resourceNames: conflictingInfo.flatMap(\.resourceNames) 91 | ) 92 | } 93 | 94 | let resources = try resources(of: binary) 95 | for target in resources + [directory.binaryPath(of: binary)] { 96 | let symbolicURL = directory.symbolicPath(name: target.lastPathComponent) 97 | try fileSystem.removeItemIfExists(at: symbolicURL) 98 | 99 | let binaryPath = directory.binaryDirectory(of: binary).appending(path: target.lastPathComponent) 100 | try fileSystem.createSymbolicLink(at: symbolicURL, withDestinationURL: binaryPath) 101 | } 102 | } 103 | 104 | private func resources(of binary: ExecutableBinary) throws -> [URL] { 105 | try fileSystem.child(extension: "bundle", at: binary.parentDirectory) 106 | .filter { $0 != binary.binaryPath } 107 | } 108 | 109 | private func extractConflictInfos(binary: ExecutableBinary) throws -> [ConflictInfo] { 110 | let resourceNames = try resources(of: binary).map(\.lastPathComponent) 111 | 112 | let conflictingResourcesInBin = try fileSystem.child(at: directory.bin) 113 | .filter { resourceNames.contains($0.lastPathComponent) } 114 | .map(\.lastPathComponent) 115 | 116 | let info = nestInfoController.getInfo() 117 | 118 | let installedCommands = info.commands.compactMap { commandName, commands -> (String, NestInfo.Command)? in 119 | guard let command = commands.first(where: { isLinked(name: commandName, commend: $0) }) else { return nil } 120 | return (commandName, command) 121 | } 122 | 123 | let conflictingInfos = installedCommands.compactMap { name, command -> ConflictInfo? in 124 | if name == binary.commandName { 125 | return nil 126 | } 127 | let resourceNames = command.resourcePaths 128 | .map { directory.url($0) } 129 | .map(\.lastPathComponent) 130 | let conflictingResourceNames = conflictingResourcesInBin.filter { resourceName in 131 | resourceNames.contains(resourceName) 132 | } 133 | if conflictingResourceNames.isEmpty { 134 | return nil 135 | } 136 | return ConflictInfo(commandName: name, resourceNames: conflictingResourceNames) 137 | } 138 | return conflictingInfos 139 | } 140 | 141 | struct ConflictInfo { 142 | let commandName: String 143 | let resourceNames: [String] 144 | } 145 | } 146 | 147 | extension ArtifactBundleManager { 148 | var nestInfoController: NestInfoController { 149 | NestInfoController(directory: directory, fileSystem: fileSystem) 150 | } 151 | 152 | public func isLinked(name: String, commend: NestInfo.Command) -> Bool { 153 | (try? self.linkedFilePath(commandName: name)) == commend.binaryPath 154 | } 155 | 156 | private func linkedFilePath(commandName: String) throws -> String { 157 | let urlString = try fileSystem.destinationOfSymbolicLink(atPath: directory.symbolicPath(name: commandName).path()) 158 | let url = URL(filePath: urlString) 159 | return directory.relativePath(url) 160 | } 161 | } 162 | 163 | enum ArtifactBundleManagerError: LocalizedError { 164 | case resourceConflicting(commandName: String, conflictingNames: [String], resourceNames: [String]) 165 | 166 | var errorDescription: String? { 167 | switch self { 168 | case .resourceConflicting(let name, let conflictingNames, let resourceNames): 169 | return """ 170 | \(conflictingNames.joined(separator: ", ")) and \(name) are not installed at the same, because resource names (\(resourceNames.joined(separator: ","))) are conflicting. 171 | """ 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Sources/NestKit/Assets/AssetRegistryClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A client that fetches information of assets which may contain an artifact bundle. 4 | public protocol AssetRegistryClient: Sendable { 5 | 6 | /// Fetches information of assets in the repository which may contain an artifact bundle corresponding to the version 7 | /// - Parameters: 8 | /// - repositoryURL: A url of a repository. 9 | /// - version: A version of asset you want. 10 | /// - Returns: An asset information. 11 | func fetchAssets(repositoryURL: URL, version: GitVersion) async throws -> AssetInformation 12 | 13 | /// Fetches information of assets applying excluded targets in the repository which may contain an artifact bundle corresponding to the version 14 | /// - Parameters: 15 | /// - repositoryURL: A url of a repository. 16 | /// - version: latestAvailableRelease only. 17 | /// - excludingTargets: excluding targets. 18 | /// - Returns: An asset information. 19 | func fetchAssetsApplyingExcludedTargets(repositoryURL: URL, version: GitVersion, excludingTargets: [String]) async throws -> AssetInformation 20 | } 21 | 22 | public struct AssetInformation: Sendable { 23 | public var tagName: String 24 | public var assets: [Asset] 25 | 26 | public init(tagName: String, assets: [Asset]) { 27 | self.tagName = tagName 28 | self.assets = assets 29 | } 30 | } 31 | 32 | public struct Asset: Sendable, Equatable { 33 | /// A file name of this asset. 34 | public var fileName: String 35 | 36 | /// A url indicating a place of this asset. 37 | public var url: URL 38 | 39 | public init(fileName: String, url: URL) { 40 | self.fileName = fileName 41 | self.url = url 42 | } 43 | } 44 | 45 | // MARK: - Errors 46 | 47 | public enum AssetRegistryClientError: LocalizedError, Hashable, Sendable { 48 | case notFound 49 | case noMatchApplyingExcludedTarget 50 | 51 | public var errorDescription: String? { 52 | switch self { 53 | case .notFound: "Not found for the repository." 54 | case .noMatchApplyingExcludedTarget: "Not match applying excluded version." 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /Sources/NestKit/Assets/AssetRegistryClientBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public struct AssetRegistryClientBuilder: Sendable { 5 | private let httpClient: any HTTPClient 6 | private let registryConfigs: RegistryConfigs? 7 | private let logger: Logger 8 | 9 | public init(httpClient: some HTTPClient, registryConfigs: RegistryConfigs?, logger: Logger) { 10 | self.httpClient = httpClient 11 | self.registryConfigs = registryConfigs 12 | self.logger = logger 13 | } 14 | 15 | /// Build AssetRegistryClient based on the given git url. 16 | /// 17 | /// > Note: This function currently supports only GitHub. 18 | public func build(for url: GitURL) -> any AssetRegistryClient { 19 | // Only GitHub is supported now. 20 | GitHubAssetRegistryClient(httpClient: httpClient, registryConfigs: registryConfigs?.github, logger: logger) 21 | } 22 | 23 | /// Build AssetRegistryClient based on the given url. 24 | /// 25 | /// > Note: This function currently supports only GitHub. 26 | public func build(for url: URL) -> any AssetRegistryClient { 27 | build(for: .url(url)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/NestKit/ChecksumCalculator.swift: -------------------------------------------------------------------------------- 1 | public protocol ChecksumCalculator: Sendable { 2 | func calculate(_ path: String) async throws -> String 3 | } 4 | 5 | public struct SwiftChecksumCalculator: ChecksumCalculator { 6 | let swift: SwiftCommand 7 | 8 | public init(swift: SwiftCommand) { 9 | self.swift = swift 10 | } 11 | 12 | public init(processExecutor: ProcessExecutor) { 13 | self.swift = SwiftCommand(executor: processExecutor) 14 | } 15 | 16 | public func calculate(_ path: String) async throws -> String { 17 | try await swift.computeCheckSum(path: path) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/NestKit/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public struct Configuration: Sendable { 5 | public var httpClient: any HTTPClient 6 | public var fileSystem: any FileSystem 7 | public var fileDownloader: any FileDownloader 8 | public var workingDirectory: URL 9 | public var assetRegistryClientBuilder: AssetRegistryClientBuilder 10 | public var nestDirectory: NestDirectory 11 | public var artifactBundleManager: ArtifactBundleManager 12 | public var logger: Logger 13 | 14 | public init( 15 | httpClient: some HTTPClient, 16 | fileSystem: any FileSystem, 17 | fileDownloader: some FileDownloader, 18 | workingDirectory: URL, 19 | assetRegistryClientBuilder: AssetRegistryClientBuilder, 20 | nestDirectory: NestDirectory, 21 | artifactBundleManager: ArtifactBundleManager, 22 | logger: Logger 23 | ) { 24 | self.httpClient = httpClient 25 | self.fileSystem = fileSystem 26 | self.fileDownloader = fileDownloader 27 | self.workingDirectory = workingDirectory 28 | self.assetRegistryClientBuilder = assetRegistryClientBuilder 29 | self.nestDirectory = nestDirectory 30 | self.artifactBundleManager = artifactBundleManager 31 | self.logger = logger 32 | } 33 | } 34 | 35 | extension FileManager: @unchecked Swift.Sendable {} 36 | -------------------------------------------------------------------------------- /Sources/NestKit/ExecutableBinary/ExecutableBinary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A data structure representing executable target. 4 | public struct ExecutableBinary: Codable, Sendable, Equatable { 5 | public var commandName: String 6 | public var binaryPath: URL 7 | public var version: String 8 | public var manufacturer: ExecutableManufacturer 9 | 10 | public var parentDirectory: URL { 11 | binaryPath.deletingLastPathComponent() 12 | } 13 | 14 | public init(commandName: String, binaryPath: URL, version: String, manufacturer: ExecutableManufacturer) { 15 | self.commandName = commandName 16 | self.binaryPath = binaryPath 17 | self.version = version 18 | self.manufacturer = manufacturer 19 | } 20 | } 21 | 22 | /// An enum representing manufacturer of an executable target 23 | public enum ExecutableManufacturer: Codable, Sendable, Equatable, Hashable { 24 | /// A case where the executable target is from an artifact bundle 25 | case artifactBundle(sourceInfo: ArtifactBundleSourceInfo) 26 | 27 | /// A case where the executable target is built in the local environment 28 | case localBuild(repository: Repository) 29 | } 30 | 31 | /// Informations of artifact bundle. 32 | public struct ArtifactBundleSourceInfo: Codable, Sendable, Equatable, Hashable { 33 | /// A url where the artifact bundle is located. 34 | public let zipURL: URL 35 | 36 | /// A repository of the artifact bundle. If the repository is not identified, the value can be `nil`. 37 | public let repository: Repository? 38 | 39 | public init(zipURL: URL, repository: Repository?) { 40 | self.zipURL = zipURL 41 | self.repository = repository 42 | } 43 | } 44 | 45 | public struct Repository: Codable, Sendable, Equatable, Hashable { 46 | public let reference: GitURL 47 | public let version: String 48 | 49 | public init(reference: GitURL, version: String) { 50 | self.reference = reference 51 | self.version = version 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/NestKit/Extensions/Logger+extension.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | import Rainbow 3 | 4 | extension Logger.MetadataValue { 5 | public static func color(_ color: NamedColor) -> Self { 6 | .stringConvertible(color.rawValue) 7 | } 8 | } 9 | 10 | extension Logger.Metadata { 11 | public static func color(_ color: NamedColor) -> Self { ["color": .color(color)] } 12 | } 13 | 14 | extension Logger { 15 | public func error(_ error: some Error) { 16 | self.error("💥 \(error.localizedDescription)", metadata: .color(.red)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/NestKit/Extensions/URL+extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | extension URL { 5 | /// `owner/repo` format 6 | /// `pathComponents[1]` must be specified as owner and `pathComponents[2]` must be specified as repository 7 | public var reference: String? { 8 | guard pathComponents.count >= 3 else { return nil } 9 | let owner = pathComponents[1] 10 | let repo = pathComponents[2].replacingOccurrences(of: ".\(pathExtension)", with: "") 11 | return "\(owner)/\(repo)" 12 | } 13 | 14 | public var fileNameWithoutPathExtension: String { 15 | lastPathComponent.replacingOccurrences(of: ".\(pathExtension)", with: "") 16 | } 17 | 18 | public var needsUnzip: Bool { 19 | let utType = UTType(filenameExtension: pathExtension) 20 | return utType?.conforms(to: .zip) ?? false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/NestKit/FileSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ZIPFoundation 3 | 4 | public protocol FileSystem: Sendable { 5 | var homeDirectoryForCurrentUser: URL { get } 6 | var temporaryDirectory: URL { get } 7 | 8 | func createDirectory( 9 | at url: URL, 10 | withIntermediateDirectories createIntermediates: Bool, 11 | attributes: [FileAttributeKey: Any]? 12 | ) throws 13 | func contentsOfDirectory(atPath path: String) throws -> [String] 14 | func removeItem(at URL: URL) throws 15 | func child(at url: URL) throws -> [URL] 16 | func copyItem(at srcURL: URL, to dstURL: URL) throws 17 | func createSymbolicLink(at url: URL, withDestinationURL destURL: URL) throws 18 | func destinationOfSymbolicLink(atPath path: String) throws -> String 19 | func unzip( 20 | at sourceURL: URL, 21 | to destinationURL: URL, 22 | skipCRC32: Bool, 23 | allowUncontainedSymlinks: Bool, 24 | progress: Progress?, 25 | pathEncoding: String.Encoding? 26 | ) throws 27 | func fileExists(atPath path: String) -> Bool 28 | func data(at url: URL) throws -> Data 29 | func write(_ data: Data, to url: URL) throws 30 | } 31 | 32 | extension FileSystem { 33 | public func createDirectory( 34 | at url: URL, 35 | withIntermediateDirectories createIntermediates: Bool 36 | ) throws { 37 | try createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: nil) 38 | } 39 | 40 | public func unzip(at sourceURL: URL, to destinationURL: URL) throws { 41 | try unzip( 42 | at: sourceURL, 43 | to: destinationURL, 44 | skipCRC32: false, 45 | allowUncontainedSymlinks: false, 46 | progress: nil, 47 | pathEncoding: nil 48 | ) 49 | } 50 | 51 | public func child(extension extensionName: String, at url: URL) throws -> [URL] { 52 | try child(at: url) 53 | .filter { $0.pathExtension == extensionName } 54 | } 55 | 56 | public func removeItemIfExists(at path: URL) throws { 57 | if fileExists(atPath: path.path()) { 58 | try removeItem(at: path) 59 | } 60 | } 61 | 62 | public func child(at url: URL) throws -> [URL] { 63 | try contentsOfDirectory(atPath: url.path()) 64 | .map { url.appending(component: $0) } 65 | } 66 | 67 | public func removeEmptyDirectory(from path: URL, until rootPath: URL) throws { 68 | var targetPath = path 69 | while (try? contentsOfDirectory(atPath: targetPath.path()).isEmpty) ?? false, 70 | targetPath != rootPath { 71 | try removeItemIfExists(at: targetPath) 72 | targetPath = targetPath.deletingLastPathComponent() 73 | } 74 | } 75 | } 76 | 77 | extension FileManager: FileSystem { 78 | public func data(at url: URL) throws -> Data { 79 | try Data(contentsOf: url) 80 | } 81 | 82 | public func write(_ data: Data, to url: URL) throws { 83 | try data.write(to: url) 84 | } 85 | 86 | public func unzip( 87 | at sourceURL: URL, 88 | to destinationURL: URL, 89 | skipCRC32: Bool, 90 | allowUncontainedSymlinks: Bool, 91 | progress: Progress?, 92 | pathEncoding: String.Encoding? 93 | ) throws { 94 | try self.unzipItem( 95 | at: sourceURL, 96 | to: destinationURL, 97 | skipCRC32: skipCRC32, 98 | allowUncontainedSymlinks: allowUncontainedSymlinks, 99 | progress: progress, 100 | pathEncoding: pathEncoding 101 | ) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/NestKit/Git/GitCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public struct GitCommand { 5 | private let executor: any ProcessExecutor 6 | 7 | public init(executor: any ProcessExecutor) { 8 | self.executor = executor 9 | } 10 | 11 | func run(_ argument: String...) async throws -> String { 12 | let swift = try await executor.which("git") 13 | return try await executor.execute(command: swift, argument) 14 | } 15 | } 16 | 17 | extension GitCommand { 18 | public func currentBranch() async throws -> String { 19 | try await run("rev-parse", "--abbrev-ref", "HEAD") 20 | } 21 | 22 | public func clone(repositoryURL: GitURL, tag: String?, to destinationPath: URL) async throws { 23 | let cloneURL: String = switch repositoryURL { 24 | case .url(let url): url.absoluteString 25 | case .ssh(let sshURL): sshURL.stringURL 26 | } 27 | 28 | if let tag { 29 | _ = try await run("clone", "--branch", tag, cloneURL, destinationPath.path()) 30 | } else { 31 | _ = try await run("clone", cloneURL, destinationPath.path()) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/NestKit/Git/GitURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum GitURL: Sendable, Hashable, Codable { 4 | case url(URL) 5 | case ssh(SSHURL) 6 | 7 | public init(from decoder: any Decoder) throws { 8 | let container = try decoder.singleValueContainer() 9 | let string = try container.decode(String.self) 10 | guard let value = Self.parse(from: string) else { 11 | throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Invalid format")) 12 | } 13 | self = value 14 | } 15 | 16 | public func encode(to encoder: any Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | try container.encode(stringURL) 19 | } 20 | 21 | public static func parse(from string: String) -> GitURL? { 22 | if let sshURL = SSHURL(string: string) { 23 | return .ssh(sshURL) 24 | } 25 | 26 | // A case the string is `{Owner}/{Repository Name}`. 27 | if let gitHubRepositoryName = GitHubRepositoryName.parseOmittedStyle(from: string) { 28 | return .url(gitHubRepositoryName.httpsURL) 29 | } 30 | 31 | guard let url = URL(string: string) else { return nil } 32 | 33 | // github.com/xxx/yyy or https://github.com/xxx/yyy 34 | if url.host() != nil { 35 | let fileNameWithoutPathExtension = url.fileNameWithoutPathExtension 36 | return .url(url.deletingLastPathComponent().appending(path: fileNameWithoutPathExtension)) 37 | } 38 | 39 | if url.pathComponents.count >= 2, 40 | let url = URL(string: "https://\(string)") { 41 | return .url(url) 42 | } 43 | 44 | return nil 45 | } 46 | 47 | public var repositoryName: String { 48 | switch self { 49 | case .url(let url): url.fileNameWithoutPathExtension 50 | case .ssh(let sshURL): 51 | String(sshURL.path.split(separator: "/").last ?? "") 52 | .replacingOccurrences(of: ".git", with: "") 53 | } 54 | } 55 | 56 | /// `owner/repo` format 57 | public var reference: String? { 58 | switch self { 59 | case let .url(url): 60 | return url.reference 61 | case let .ssh(sshURL): 62 | return sshURL.path.split(separator: ":").last?.replacingOccurrences(of: ".git", with: "") 63 | } 64 | } 65 | 66 | public var stringURL: String { 67 | switch self { 68 | case .url(let url): url.absoluteString 69 | case .ssh(let sshURL): sshURL.stringURL 70 | } 71 | } 72 | } 73 | 74 | public struct SSHURL: Sendable, Hashable, Codable { 75 | public let user: String 76 | public let host: String 77 | public let path: String 78 | 79 | public init(user: String, host: String, path: String) { 80 | self.user = user 81 | self.host = host 82 | self.path = path 83 | } 84 | 85 | public init?(string: String) { 86 | // git@github.com/xxx/yyy 87 | let regex = /^(?[a-zA-Z0-9_]+)@(?[a-zA-Z0-9.-]+):(?[a-zA-Z0-9_.\/-]+)(\.git)?$/ 88 | 89 | guard let match = try? regex.wholeMatch(in: string) else { return nil } 90 | 91 | let user = String(match.output.user) 92 | let host = String(match.output.host) 93 | let path = String(match.output.path) 94 | 95 | self.init(user: user, host: host, path: path) 96 | } 97 | 98 | var stringURL: String { 99 | "\(user)@\(host):\(path)" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/NestKit/Git/GitVersion.swift: -------------------------------------------------------------------------------- 1 | public enum GitVersion: Sendable, Equatable { 2 | case latestRelease 3 | case tag(String) 4 | 5 | public var description: String { 6 | switch self { 7 | case .latestRelease: "latest" 8 | case .tag(let string): string 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/ExcludedTarget.swift: -------------------------------------------------------------------------------- 1 | /// {owner}/{repo} and (optional) version pairs to exclude 2 | public struct ExcludedTarget: Equatable, Sendable { 3 | /// A reference to a repository. 4 | /// {owner}/{repo} 5 | public let reference: String 6 | public let version: String? 7 | 8 | public init(reference: String, version: String?) { 9 | self.reference = reference 10 | self.version = version 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/GitHubAssetRegistryClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPTypes 3 | import HTTPTypesFoundation 4 | import Logging 5 | 6 | public struct GitHubAssetRegistryClient: AssetRegistryClient { 7 | private let httpClient: any HTTPClient 8 | private let registryConfigs: GitHubRegistryConfigs? 9 | private let logger: Logger 10 | 11 | public init(httpClient: some HTTPClient, registryConfigs: GitHubRegistryConfigs?, logger: Logger) { 12 | self.httpClient = httpClient 13 | self.registryConfigs = registryConfigs 14 | self.logger = logger 15 | } 16 | 17 | public func fetchAssets(repositoryURL: URL, version: GitVersion) async throws -> AssetInformation { 18 | let assetURL = try GitHubURLBuilder.assetURL(repositoryURL, version: version) 19 | 20 | let assetResponse = try await fetchData(GitHubAssetResponse.self, requestURL: assetURL, repositoryURL: repositoryURL) 21 | let assets = assetResponse.assets.map { asset in 22 | Asset(fileName: asset.name, url: asset.browserDownloadURL) 23 | } 24 | return AssetInformation(tagName: assetResponse.tagName, assets: assets) 25 | } 26 | 27 | public func fetchAssetsApplyingExcludedTargets( 28 | repositoryURL: URL, 29 | version: GitVersion, 30 | excludingTargets: [String] 31 | ) async throws -> AssetInformation { 32 | let assetURL = try GitHubURLBuilder.releasesAssetURL(repositoryURL) 33 | let assetResponses = try await fetchData([GitHubAssetResponse].self, requestURL: assetURL, repositoryURL: repositoryURL) 34 | 35 | guard let matchedAssetResponse = assetResponses.first(where: { !excludingTargets.contains($0.tagName) }) else { 36 | throw AssetRegistryClientError.noMatchApplyingExcludedTarget 37 | } 38 | let assets = matchedAssetResponse.assets 39 | .map { Asset(fileName: $0.name, url: $0.browserDownloadURL) } 40 | return AssetInformation(tagName: matchedAssetResponse.tagName, assets: assets) 41 | } 42 | 43 | private func fetchData(_: T.Type, requestURL: URL, repositoryURL: URL) async throws -> T { 44 | var request = HTTPRequest(url: requestURL) 45 | request.headerFields = [ 46 | .accept: "application/vnd.github+json", 47 | .gitHubAPIVersion: "2022-11-28" 48 | ] 49 | 50 | guard let repositoryHost = repositoryURL.host() else { fatalError("Unknown host") } 51 | if let config = registryConfigs?.config(for: repositoryURL) { 52 | logger.debug("GitHub token for \(repositoryHost) is passed from \(config.environmentVariable)") 53 | request.headerFields[.authorization] = "Bearer \(config.token)" 54 | } else { 55 | logger.debug("GitHub token for \(repositoryHost) is not provided.") 56 | } 57 | 58 | logger.debug("Request: \(repositoryURL)") 59 | logger.debug("Request: \(request.headerFields)") 60 | let (data, response) = try await httpClient.data(for: request) 61 | logger.debug("Response: \(data.humanReadableJSONString() ?? "No data")") 62 | logger.debug("Status: \(response.status)") 63 | 64 | if response.status == .notFound { 65 | throw AssetRegistryClientError.notFound 66 | } 67 | return try JSONDecoder().decode(T.self, from: data) 68 | } 69 | } 70 | 71 | extension HTTPField.Name { 72 | static let gitHubAPIVersion = HTTPField.Name("X-GitHub-Api-Version")! 73 | } 74 | 75 | extension Data { 76 | fileprivate func humanReadableJSONString() -> String? { 77 | guard let jsonObject = try? JSONSerialization.jsonObject(with: self, options: []), 78 | let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted) 79 | else { return nil } 80 | return String(data: prettyPrintedData, encoding: .utf8) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/GitHubAssetResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GitHubAssetResponse: Codable { 4 | let assets: [GitHubAsset] 5 | let tagName: String 6 | 7 | enum CodingKeys: String, CodingKey { 8 | case assets 9 | case tagName = "tag_name" 10 | } 11 | } 12 | 13 | struct GitHubAsset: Codable { 14 | let name: String 15 | let browserDownloadURL: URL 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case name 19 | case browserDownloadURL = "browser_download_url" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/GitHubRegistryConfigs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol EnvironmentVariableStorage { 4 | subscript(_ key: String) -> String? { get } 5 | } 6 | 7 | public struct SystemEnvironmentVariableStorage: EnvironmentVariableStorage { 8 | public subscript(_ key: String) -> String? { 9 | ProcessInfo.processInfo.environment[key] 10 | } 11 | 12 | public init() { } 13 | } 14 | 15 | public struct RegistryConfigs: Sendable { 16 | public var github: GitHubRegistryConfigs? 17 | 18 | public init(github: GitHubRegistryConfigs?) { 19 | self.github = github 20 | } 21 | } 22 | 23 | /// A container of GitHub server configurations. 24 | public struct GitHubRegistryConfigs: Sendable { 25 | /// An enum value to represent GitHub sever host. 26 | public enum Host: Hashable, Sendable { 27 | /// A value indicates github.com 28 | case githubCom 29 | 30 | /// A value indicates an GitHub Enterprise Server. 31 | case custom(String) 32 | 33 | init(_ host: String) { 34 | switch host { 35 | case "github.com": self = .githubCom 36 | default: self = .custom(host) 37 | } 38 | } 39 | 40 | } 41 | 42 | /// A struct to contain the server configuration. 43 | struct Config : Sendable { 44 | /// Where the token come from. 45 | var environmentVariable: String 46 | /// GitHub API token. 47 | var token: String 48 | } 49 | 50 | public typealias GitHubServerHostName = String 51 | public typealias EnvironmentVariableName = String 52 | 53 | /// Resolve server configurations from environment variable names. 54 | /// If GH_TOKEN environment variable is set, the token for GitHub.com will be that. 55 | /// If other values are set on environmentVariableNames, the value will be overwritten. 56 | /// - Parameters environmentVariableNames A dictionary of environment variable names with hostname as key. 57 | /// - Parameters environmentVariablesStorage A container of environment variables. 58 | /// - Returns A new server configuration. 59 | public static func resolve( 60 | environmentVariableNames: [GitHubServerHostName: EnvironmentVariableName], 61 | environmentVariablesStorage: any EnvironmentVariableStorage = SystemEnvironmentVariableStorage() 62 | ) -> GitHubRegistryConfigs { 63 | let loadedConfigs: [Host: Config] = environmentVariableNames.reduce(into: [:]) { (registries, pair) in 64 | let (host, environmentVariableName) = pair 65 | if let token = environmentVariablesStorage[environmentVariableName] { 66 | let host = Host(host) 67 | registries[host] = Config(environmentVariable: environmentVariableName, token: token) 68 | } 69 | } 70 | return .init(registries: loadedConfigs) 71 | } 72 | 73 | private var registries: [Host: Config] 74 | 75 | private init(registries: [Host: Config]) { 76 | self.registries = registries 77 | } 78 | 79 | /// Get the server configuration for URL. It will be resolved from its host. 80 | /// - Parameters url An URL. 81 | /// - Parameters environmentVariablesStorage A container of environment variables. 82 | /// - Returns A config for the host. 83 | func config(for url: URL, environmentVariablesStorage: any EnvironmentVariableStorage = SystemEnvironmentVariableStorage()) -> Config? { 84 | guard let hostString = url.host() else { 85 | return nil 86 | } 87 | let host = Host(hostString) 88 | switch host { 89 | case .githubCom: 90 | return registries[host] ?? Config(environmentVariableName: "GH_TOKEN", environmentVariablesStorage: environmentVariablesStorage) 91 | case .custom: 92 | return registries[host] ?? Config(environmentVariableName: "GHE_TOKEN", environmentVariablesStorage: environmentVariablesStorage) 93 | } 94 | } 95 | } 96 | 97 | extension GitHubRegistryConfigs.Config { 98 | fileprivate init?(environmentVariableName: String, environmentVariablesStorage: some EnvironmentVariableStorage) { 99 | if let environmentVariableValue = environmentVariablesStorage[environmentVariableName] { 100 | self.environmentVariable = environmentVariableName 101 | self.token = environmentVariableValue 102 | } else { 103 | return nil 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/GitHubRepositoryName.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct GitHubRepositoryName: Sendable, Hashable { 4 | public var owner: String 5 | public var name: String 6 | 7 | var httpsURL: URL { 8 | URL(string: "https://\(Self.gitHubHost)/\(owner)/\(name)")! 9 | } 10 | 11 | public init?(owner: String, name: String) { 12 | guard Self.validate(owner), Self.validate(name) else { return nil } 13 | self.owner = owner 14 | self.name = name 15 | } 16 | 17 | public static func parse(from string: String) -> Self? { 18 | if let gitURL = GitURL.parse(from: string), 19 | let self = parse(from: gitURL) { 20 | return self 21 | } 22 | 23 | return parseOmittedStyle(from: string) 24 | } 25 | 26 | /// Parse string if it follows `{owner}/{repository name}` format. 27 | public static func parseOmittedStyle(from string: String) -> Self? { 28 | let components = string.split(separator: "/").map { String($0) } 29 | if components.count == 2 { 30 | return GitHubRepositoryName(owner: components[0], name: components[1].removingGitExtension()) 31 | } 32 | return nil 33 | } 34 | 35 | public static func parse(from gitURL: GitURL) -> Self? { 36 | switch gitURL { 37 | case let .url(url): parse(from: url) 38 | case let .ssh(sshURL): parse(from: sshURL) 39 | } 40 | } 41 | 42 | public static func parse(from url: URL) -> Self? { 43 | guard url.host() == gitHubHost else { return nil } 44 | let components = url.pathComponents.compactMap { String($0) } 45 | 46 | // http://github.com/owner/name/main/... 47 | guard 2 <= components.count else { 48 | return nil 49 | } 50 | return GitHubRepositoryName(owner: components[1], name: components[2].removingGitExtension()) 51 | } 52 | 53 | public static func parse(from sshURL: SSHURL) -> Self? { 54 | guard sshURL.host == gitHubHost else { 55 | return nil 56 | } 57 | 58 | let string = sshURL.path.removingGitExtension() 59 | return parse(from: string) 60 | } 61 | } 62 | 63 | extension GitHubRepositoryName { 64 | private static var gitHubHost: String { "github.com" } 65 | 66 | private static func validate(_ input: String) -> Bool { 67 | let invalidCharacters: [Character] = ["@", ":", "/"] 68 | return !input.contains { invalidCharacters.contains($0) } 69 | } 70 | } 71 | 72 | extension String { 73 | fileprivate func removingGitExtension() -> String { 74 | replacingOccurrences(of: ".git", with: "") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/NestKit/GitHub/GitHubURLBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum GitHubURLBuilder { 4 | /// Builds a download url for an asset in a specified version in a specified repository. 5 | /// - Parameters: 6 | /// - url: A URL to a repository 7 | /// - version: A specified version 8 | /// - fileName: A specified file name. 9 | public static func assetDownloadURL(_ url: URL, version: String, fileName: String) -> URL { 10 | url.appending(components: "releases", "download", version, fileName) 11 | } 12 | 13 | static func assetURL(_ url: URL, version: GitVersion) throws -> URL { 14 | let (baseURL, owner, repository) = try getValidBaseURLAndRepository(url) 15 | 16 | switch version { 17 | case .latestRelease: 18 | return baseURL.appending(components: "repos", owner, repository, "releases", "latest") 19 | case .tag(let string): 20 | return baseURL.appending(components: "repos", owner, repository, "releases", "tags", string) 21 | } 22 | } 23 | 24 | static func releasesAssetURL(_ url: URL) throws -> URL { 25 | let (baseURL, owner, repository) = try getValidBaseURLAndRepository(url) 26 | 27 | // Since the latest version is returned in descending order, pagination isn't supported. 28 | return baseURL.appending(components: "repos", owner, repository, "releases") 29 | } 30 | 31 | private static func getValidBaseURLAndRepository(_ url: URL) throws -> (baseURL: URL, owner: String, repository: String) { 32 | guard url.pathComponents.count >= 3 else { 33 | throw InvalidURLError(url: url) 34 | } 35 | 36 | let owner = url.pathComponents[1] 37 | let repository = url.pathComponents[2] 38 | 39 | guard let baseURL = baseAPIURL(from: url) else { 40 | throw InvalidURLError(url: url) 41 | } 42 | 43 | return (baseURL, owner, repository) 44 | } 45 | 46 | private static func baseAPIURL(from url: URL) -> URL? { 47 | if url.host() == "github.com" { 48 | return URL(string: "https://api.github.com/") 49 | } 50 | else { 51 | // GitHub Enterprise 52 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 53 | components?.path = "" 54 | components?.query = nil 55 | components?.fragment = nil 56 | return components?.url?.appending(components: "api", "v3") 57 | } 58 | } 59 | } 60 | 61 | extension GitHubURLBuilder { 62 | public struct InvalidURLError: LocalizedError { 63 | public var url: URL 64 | public var failureReason: String? { 65 | "Invalid url: \(url)" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/NestKit/HTTPClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPTypes 3 | import HTTPTypesFoundation 4 | 5 | public protocol HTTPClient: Sendable { 6 | func data(for request: HTTPRequest) async throws -> (Data, HTTPResponse) 7 | func download(for request: HTTPRequest) async throws -> (URL, HTTPResponse) 8 | } 9 | 10 | extension URLSession: HTTPClient { 11 | public func download(for request: HTTPRequest) async throws -> (URL, HTTPResponse) { 12 | try await download(for: request, delegate: nil) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/NestKit/NestDirectory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A directory structure for nest. 4 | public struct NestDirectory: Sendable { 5 | public let rootDirectory: URL 6 | 7 | public init(rootDirectory: URL) { 8 | self.rootDirectory = rootDirectory 9 | } 10 | } 11 | 12 | extension NestDirectory { 13 | 14 | /// `root/info.json` 15 | public var infoJSON: URL { 16 | rootDirectory.appending(component: "info.json") 17 | } 18 | 19 | /// `root/bin` 20 | public var bin: URL { 21 | rootDirectory.appending(component: "bin") 22 | } 23 | 24 | /// `root/artifacts` 25 | public var artifacts: URL { 26 | rootDirectory.appending(component: "artifacts") 27 | } 28 | 29 | /// `root/artifacts/{source}` 30 | public func source(_ manufacturer: ExecutableManufacturer) -> URL { 31 | func sourceIdentifier(_ url: URL) -> String { 32 | let scheme = url.scheme 33 | let host = url.host() 34 | let pathComponents = Array(url.pathComponents.dropFirst()) 35 | let components = pathComponents + [host, scheme].compactMap { $0 } 36 | return components.joined(separator: "_") 37 | } 38 | 39 | func sourceIdentifier(_ gitURL: GitURL) -> String { 40 | switch gitURL { 41 | case .url(let url): return sourceIdentifier(url) 42 | case .ssh(let sshURL): 43 | let pathComponents = sshURL.path.split(separator: "/").compactMap { String($0) } 44 | return (pathComponents + [sshURL.host, sshURL.user]).joined(separator: "_") 45 | } 46 | } 47 | 48 | let component = switch manufacturer { 49 | case .artifactBundle(let sourceInfo): 50 | sourceInfo.repository.map { sourceIdentifier($0.reference) } ?? sourceIdentifier(sourceInfo.zipURL) 51 | case .localBuild(let repository): sourceIdentifier(repository.reference) 52 | } 53 | return artifacts.appending(component: component) 54 | } 55 | 56 | 57 | /// `root/artifacts/{source}/{version}` 58 | public func version(manufacturer: ExecutableManufacturer, version: String) -> URL { 59 | source(manufacturer).appending(path: version) 60 | } 61 | 62 | /// `root/artifacts/{source}/{version}/{build kind}` 63 | public func binaryDirectory(manufacturer: ExecutableManufacturer, version: String) -> URL { 64 | let directoryName = switch manufacturer { 65 | case .artifactBundle(let sourceInfo): 66 | sourceInfo.zipURL.lastPathComponent 67 | .replacingOccurrences(of: ".zip", with: "") 68 | .replacingOccurrences(of: ".artifactbundle", with: "") 69 | case .localBuild: "local_build" 70 | } 71 | return self.version(manufacturer: manufacturer, version: version).appending(path: directoryName) 72 | } 73 | 74 | /// `root/artifacts/{source}/{version}/{build kind}` 75 | public func binaryDirectory(of binary: ExecutableBinary) -> URL { 76 | let version = switch binary.manufacturer { 77 | case .artifactBundle(let sourceInfo): sourceInfo.repository?.version ?? "unknown" 78 | case .localBuild(let repository): repository.version 79 | } 80 | return binaryDirectory(manufacturer: binary.manufacturer, version: version) 81 | } 82 | 83 | /// `root/artifacts/{source}/{version}/{build kind}/{binary}` 84 | public func binaryPath(of binary: ExecutableBinary) -> URL { 85 | binaryDirectory(of: binary).appending(path: binary.commandName) 86 | } 87 | 88 | /// `root/bin/{binary}` 89 | public func symbolicPath(name: String) -> URL { 90 | bin.appending(path: name) 91 | } 92 | 93 | public func relativePath(_ url: URL) -> String { 94 | url.absoluteString.replacingOccurrences(of: rootDirectory.absoluteString, with: "") 95 | } 96 | 97 | public func url(_ path: String) -> URL { 98 | rootDirectory.appending(path: path) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/NestKit/NestFileDownloader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import HTTPTypes 4 | import HTTPTypesFoundation 5 | import ZIPFoundation 6 | 7 | public protocol FileDownloader: Sendable { 8 | func download(url: URL) async throws -> URL 9 | } 10 | 11 | public struct NestFileDownloader: FileDownloader { 12 | let httpClient: any HTTPClient 13 | 14 | public init(httpClient: some HTTPClient) { 15 | self.httpClient = httpClient 16 | } 17 | 18 | public func download(url: URL) async throws -> URL { 19 | let request = HTTPRequest(url: url) 20 | let (downloadedFilePath, response) = try await httpClient.download(for: request) 21 | if response.status == .notFound { 22 | throw FileDownloaderError.notFound(url: url) 23 | } 24 | return downloadedFilePath 25 | } 26 | } 27 | 28 | enum FileDownloaderError: LocalizedError, Equatable { 29 | case notFound(url: URL) 30 | 31 | var errorDescription: String? { 32 | switch self { 33 | case .notFound(let url): 34 | "Not found: \(url.absoluteString)" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/NestKit/NestInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A data structure representing info.json of nest. 4 | public struct NestInfo: Codable, Sendable, Equatable, Hashable { 5 | public var version: String 6 | public var commands: [String: [Command]] 7 | 8 | public init(version: String, commands: [String: [Command]]) { 9 | self.version = version 10 | self.commands = commands 11 | } 12 | } 13 | 14 | extension NestInfo { 15 | public static let currentVersion = "1" 16 | 17 | public struct Command: Codable, Sendable, Equatable, Hashable { 18 | public var version: String 19 | public var binaryPath: String 20 | public var resourcePaths: [String] 21 | public var manufacturer: ExecutableManufacturer 22 | 23 | public init(version: String, binaryPath: String, resourcePaths: [String], manufacturer: ExecutableManufacturer) { 24 | self.version = version 25 | self.binaryPath = binaryPath 26 | self.resourcePaths = resourcePaths 27 | self.manufacturer = manufacturer 28 | } 29 | 30 | public var repository: Repository? { 31 | switch manufacturer { 32 | case .artifactBundle(let sourceInfo): sourceInfo.repository 33 | case .localBuild(let repository): repository 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/NestKit/NestInfoController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct NestInfoController { 4 | private let directory: NestDirectory 5 | private let fileSystem: any FileSystem 6 | 7 | public init(directory: NestDirectory, fileSystem: some FileSystem) { 8 | self.directory = directory 9 | self.fileSystem = fileSystem 10 | } 11 | 12 | public func getInfo() -> NestInfo { 13 | guard fileSystem.fileExists(atPath: directory.infoJSON.path()), 14 | let data = try? fileSystem.data(at: directory.infoJSON), 15 | let nestInfo = try? JSONDecoder().decode(NestInfo.self, from: data) 16 | else { 17 | return NestInfo(version: NestInfo.currentVersion, commands: [:]) 18 | } 19 | return nestInfo 20 | } 21 | 22 | func remove(command: String, version: String) throws { 23 | try updateInfo { info in 24 | info.commands[command] = info.commands[command]?.filter { $0.version != version } 25 | if info.commands[command]?.isEmpty ?? false { 26 | info.commands.removeValue(forKey: command) 27 | } 28 | } 29 | } 30 | 31 | func add(name: String, command: NestInfo.Command) throws { 32 | try updateInfo { info in 33 | var commands = info.commands[name, default: []] 34 | commands = commands.filter { $0.binaryPath != command.binaryPath } 35 | commands.append(command) 36 | info.commands[name] = commands 37 | } 38 | } 39 | } 40 | 41 | extension NestInfoController { 42 | private func updateInfo(_ updater: (inout NestInfo) -> Void) throws { 43 | var infoJSON: NestInfo 44 | if fileSystem.fileExists(atPath: directory.infoJSON.path()) { 45 | let data = try fileSystem.data(at: directory.infoJSON) 46 | infoJSON = try JSONDecoder().decode(NestInfo.self, from: data) 47 | } else { 48 | infoJSON = NestInfo(version: NestInfo.currentVersion, commands: [:]) 49 | } 50 | updater(&infoJSON) 51 | 52 | // Format 53 | for (name, commands) in infoJSON.commands { 54 | infoJSON.commands[name] = commands.sorted(by: { $0.version >= $1.version }) 55 | } 56 | 57 | let encoder = JSONEncoder() 58 | #if DEBUG 59 | encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys, .prettyPrinted] 60 | #else 61 | encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] 62 | #endif 63 | let updateData = try encoder.encode(infoJSON) 64 | try fileSystem.write(updateData, to: directory.infoJSON) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftCommand+Build.swift: -------------------------------------------------------------------------------- 1 | extension SwiftCommand { 2 | public func buildForRelease() async throws { 3 | _ = try await run("build", "-c", "release") 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftCommand+Checksum.swift: -------------------------------------------------------------------------------- 1 | extension SwiftCommand { 2 | public func computeCheckSum(path: String) async throws -> String { 3 | try await run("package", "compute-checksum", path) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftCommand+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SwiftCommand { 4 | public func description() async throws -> SwiftPackageDescription { 5 | let json = try await run("package", "describe", "--type", "json") 6 | 7 | // WORKAROUND 8 | // The outputs of describe command sometime contains warning like this message. 9 | // https://github.com/swiftlang/swift-package-manager/blob/db9fef21d000dd475816951d52f8d32077939e81/Sources/PackageLoading/TargetSourcesBuilder.swift#L193 10 | // To address the issue, string until "{" is removed here. 11 | let cleanedJson = removePrefixUpToFirstBrace(json) 12 | return try JSONDecoder().decode(SwiftPackageDescription.self, from: cleanedJson.data(using: .utf8)!) 13 | } 14 | 15 | private func removePrefixUpToFirstBrace(_ input: String) -> String { 16 | if let index = input.firstIndex(of: "{") { 17 | String(input[index...]) 18 | } else { 19 | input 20 | } 21 | } 22 | } 23 | 24 | public struct SwiftPackageDescription: Decodable { 25 | public var products: [Product] 26 | 27 | public init(products: [Product]) { 28 | self.products = products 29 | } 30 | 31 | public var executableNames: [String] { 32 | products.compactMap { product in 33 | product.type == .executable ? product.name : nil 34 | } 35 | } 36 | } 37 | 38 | extension SwiftPackageDescription { 39 | public struct Product: Decodable, Equatable { 40 | public var name: String 41 | public var type: ProductType 42 | 43 | public init(name: String, type: ProductType) { 44 | self.name = name 45 | self.type = type 46 | } 47 | } 48 | 49 | // This enum refers https://github.com/apple/swift-package-manager/blob/main/Sources/PackageModel/Product.swift 50 | public enum ProductType: Equatable, Hashable, Sendable, Decodable { 51 | public enum LibraryType: String, Codable, Sendable { 52 | case `static` 53 | case `dynamic` 54 | case automatic 55 | } 56 | 57 | case library(LibraryType) 58 | case executable 59 | case snippet 60 | case plugin 61 | case test 62 | case `macro` 63 | 64 | enum CodingKeys: CodingKey { 65 | case library 66 | case executable 67 | case snippet 68 | case plugin 69 | case test 70 | case macro 71 | } 72 | 73 | public init(from decoder: Decoder) throws { 74 | let values = try decoder.container(keyedBy: CodingKeys.self) 75 | guard let key = values.allKeys.first(where: values.contains) else { 76 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Did not find a matching key")) 77 | } 78 | switch key { 79 | case .library: 80 | var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) 81 | let a1 = try unkeyedValues.decode(ProductType.LibraryType.self) 82 | self = .library(a1) 83 | case .test: self = .test 84 | case .executable: self = .executable 85 | case .snippet: self = .snippet 86 | case .plugin: self = .plugin 87 | case .macro: self = .macro 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftCommand+TargetInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SwiftCommand { 4 | public func targetInfo() async throws -> SwiftTargetInfo { 5 | let json = try await run("-print-target-info") 6 | return try JSONDecoder().decode(SwiftTargetInfo.self, from: json.data(using: .utf8)!) 7 | } 8 | } 9 | 10 | public struct SwiftTargetInfo: Codable { 11 | public let target: SwiftTarget 12 | 13 | public init(target: SwiftTarget) { 14 | self.target = target 15 | } 16 | } 17 | 18 | public struct SwiftTarget: Codable { 19 | public let unversionedTriple: String 20 | 21 | public init(unversionedTriple: String) { 22 | self.unversionedTriple = unversionedTriple 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public struct SwiftCommand: Sendable { 5 | private let executor: any ProcessExecutor 6 | 7 | public init(executor: some ProcessExecutor) { 8 | self.executor = executor 9 | } 10 | 11 | func run(_ argument: String...) async throws -> String { 12 | let swift = try await executor.which("swift") 13 | return try await executor.execute(command: swift, argument) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NestKit/Swift/SwiftPackage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// A data structure representing Swift Package. 5 | public struct SwiftPackage: Sendable { 6 | let rootDirectory: URL 7 | let executorBuilder: any ProcessExecutorBuilder 8 | 9 | public init( 10 | at rootDirectory: URL, 11 | executorBuilder: any ProcessExecutorBuilder 12 | ) { 13 | self.rootDirectory = rootDirectory 14 | self.executorBuilder = executorBuilder 15 | } 16 | 17 | /// Returns a URL representing executable file. 18 | public func executableFile(name: String) -> URL { 19 | rootDirectory.appending(components: ".build", "release", name) 20 | } 21 | 22 | /// Build the package for release. 23 | public func buildForRelease() async throws { 24 | try await swift.buildForRelease() 25 | } 26 | 27 | /// Executes describe command of Swift Package. 28 | public func description() async throws -> SwiftPackageDescription { 29 | try await swift.description() 30 | } 31 | 32 | private var swift: SwiftCommand { 33 | SwiftCommand(executor: executorBuilder.build(currentDirectory: rootDirectory)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/NestKit/TripleDetector.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public struct TripleDetector { 5 | private let swiftCommand: SwiftCommand 6 | 7 | public init(swiftCommand: SwiftCommand) { 8 | self.swiftCommand = swiftCommand 9 | } 10 | 11 | public func detect() async throws -> String { 12 | try await swiftCommand.targetInfo().target.unversionedTriple 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/NestKit/Utils/ProcessExecutor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import os 4 | 5 | public protocol ProcessExecutor: Sendable { 6 | func execute(command: String, _ arguments: [String]) async throws -> String 7 | } 8 | 9 | extension ProcessExecutor { 10 | public func execute(command: String, _ arguments: String...) async throws -> String { 11 | try await execute(command: command, arguments) 12 | } 13 | 14 | public func which(_ command: String) async throws -> String { 15 | try await execute(command: "/usr/bin/which", command) 16 | } 17 | } 18 | 19 | public struct NestProcessExecutor: ProcessExecutor { 20 | let currentDirectoryURL: URL? 21 | let logger: Logging.Logger 22 | let logLevel: Logging.Logger.Level 23 | 24 | public init(currentDirectory: URL? = nil, logger: Logging.Logger, logLevel: Logging.Logger.Level = .debug) { 25 | self.currentDirectoryURL = currentDirectory 26 | self.logger = logger 27 | self.logLevel = logLevel 28 | } 29 | 30 | public func execute(command: String, _ arguments: [String]) async throws -> String { 31 | let elements = try await _execute(command: command, arguments.map { $0 }) 32 | return elements.compactMap { element in 33 | switch element { 34 | case .output(let string): string 35 | case .error: nil 36 | } 37 | }.joined() 38 | } 39 | 40 | private func _execute(command: String, _ arguments: [String]) async throws -> [StreamElement] { 41 | logger.debug("$ \(command) \(arguments.joined(separator: " "))") 42 | return try await withCheckedThrowingContinuation { continuous in 43 | let executableURL = URL(fileURLWithPath: command) 44 | do { 45 | let results = OSAllocatedUnfairLock(initialState: [StreamElement]()) 46 | 47 | let process = Process() 48 | process.currentDirectoryURL = currentDirectoryURL 49 | process.executableURL = executableURL 50 | process.arguments = arguments 51 | 52 | let outputPipe = Pipe() 53 | process.standardOutput = outputPipe 54 | 55 | outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in 56 | let availableData = fileHandle.availableData 57 | guard availableData.count != 0, 58 | let string = String(data: availableData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), 59 | !string.isEmpty 60 | else { 61 | return 62 | } 63 | logger.log(level: logLevel, "\(string)") 64 | results.withLock { $0 += [.output(string)] } 65 | } 66 | 67 | let errorPipe = Pipe() 68 | process.standardError = errorPipe 69 | errorPipe.fileHandleForReading.readabilityHandler = { fileHandle in 70 | let availableData = fileHandle.availableData 71 | guard availableData.count != 0, 72 | let string = String(data: availableData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), 73 | !string.isEmpty 74 | else { 75 | return 76 | } 77 | logger.log(level: logLevel, "\(string)", metadata: .color(.red)) 78 | results.withLock { $0 += [.error(string)] } 79 | } 80 | 81 | try process.run() 82 | process.waitUntilExit() 83 | 84 | // [Workaround] Sometimes, this code is executes before all events of `readabilityHandler` are addressed. 85 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 86 | let result = process.terminationReason == .exit && process.terminationStatus == 0 87 | if result { 88 | let returnedValue = results.withLock { $0 } 89 | continuous.resume(returning: returnedValue) 90 | } else { 91 | continuous.resume(throwing: ProcessExecutorError.failed) 92 | } 93 | } 94 | } catch { 95 | continuous.resume(throwing: error) 96 | } 97 | } 98 | } 99 | } 100 | 101 | enum StreamElement { 102 | case output(String) 103 | case error(String) 104 | 105 | var text: String { 106 | switch self { 107 | case .output(let text): return text 108 | case .error(let text): return text 109 | } 110 | } 111 | } 112 | 113 | enum ProcessExecutorError: Error { 114 | case failed 115 | } 116 | -------------------------------------------------------------------------------- /Sources/NestKit/Utils/ProcessExecutorBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | public protocol ProcessExecutorBuilder: Sendable { 5 | func build(currentDirectory: URL?) -> ProcessExecutor 6 | } 7 | 8 | extension ProcessExecutorBuilder { 9 | public func build() -> ProcessExecutor { 10 | build(currentDirectory: nil) 11 | } 12 | } 13 | 14 | public struct NestProcessExecutorBuilder: ProcessExecutorBuilder { 15 | public let logger: Logger 16 | 17 | public init(logger: Logger) { 18 | self.logger = logger 19 | } 20 | 21 | public func build(currentDirectory: URL?) -> any ProcessExecutor { 22 | NestProcessExecutor(currentDirectory: currentDirectory, logger: logger) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NestTestHelpers/FileStorageItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FileSystemItem: Equatable, Sendable { 4 | case directory(children: [String: FileSystemItem]) 5 | case file(data: Data) 6 | 7 | public mutating func remove(at components: [String]) { 8 | switch self { 9 | case .directory(var children): 10 | var components = components 11 | let component = components.removeFirst() 12 | if components.isEmpty { 13 | children.removeValue(forKey: component) 14 | } else { 15 | var child = children[component] 16 | child?.remove(at: components) 17 | children[component] = child 18 | } 19 | self = .directory(children: children) 20 | case .file: 21 | break 22 | } 23 | 24 | } 25 | 26 | public mutating func update(item: FileSystemItem, at components: [String]) { 27 | switch self { 28 | case .directory(var children): 29 | var components = components 30 | let component = components.removeFirst() 31 | if components.isEmpty { 32 | children[component] = item 33 | } else { 34 | var child = children[component] 35 | child?.update(item: item, at: components) 36 | children[component] = child 37 | } 38 | self = .directory(children: children) 39 | case .file: 40 | break 41 | } 42 | } 43 | 44 | public func item(components: [String]) -> FileSystemItem? { 45 | if components.isEmpty { 46 | return self 47 | } 48 | var components = components 49 | let component = components.removeFirst() 50 | switch self { 51 | case .directory(children: let children): 52 | return children[component]?.item(components: components) 53 | case .file: 54 | return nil 55 | } 56 | } 57 | 58 | public func printStructure(indent: Int = 0) { 59 | let space = String(repeating: " ", count: indent * 4) 60 | let nextSpace = String(repeating: " ", count: (indent + 1) * 4) 61 | switch self { 62 | case .directory(let children): 63 | print("[") 64 | for child in children { 65 | print(nextSpace + child.key + ": ", terminator: "") 66 | child.value.printStructure(indent: indent + 1) 67 | } 68 | print("\(space)]") 69 | case .file(let data): 70 | print("\(data.count) bytes") 71 | } 72 | } 73 | } 74 | 75 | extension FileSystemItem: ExpressibleByDictionaryLiteral { 76 | public typealias Key = String 77 | public typealias Value = FileSystemItem 78 | 79 | public init(dictionaryLiteral elements: (String, FileSystemItem)...) { 80 | self = .directory(children: elements.reduce(into: [String: FileSystemItem](), { partialResult, pair in 81 | partialResult[pair.0] = pair.1 82 | })) 83 | } 84 | 85 | public static var directory: Self { 86 | .directory(children: [:]) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/NestTestHelpers/MockExecutorBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | import NestKit 4 | 5 | public struct MockExecutorBuilder: ProcessExecutorBuilder { 6 | let executorClosure: @Sendable (String, [String]) async throws -> String 7 | 8 | public init(executorClosure: @escaping @Sendable (String, [String]) -> String) { 9 | self.executorClosure = executorClosure 10 | } 11 | 12 | public init(dummy: [String: String]) { 13 | self.executorClosure = { command, arguments in 14 | let command = ([command] + arguments).joined(separator: " ") 15 | guard let result = dummy[command] else { 16 | Issue.record("Unexpected commend: \(command)") 17 | return "" 18 | } 19 | return result 20 | } 21 | } 22 | 23 | public func build(currentDirectory: URL?) -> any NestKit.ProcessExecutor { 24 | MockProcessExecutor(executorClosure: executorClosure) 25 | } 26 | } 27 | 28 | public struct MockProcessExecutor: ProcessExecutor { 29 | let executorClosure: @Sendable (String, [String]) async throws -> String 30 | 31 | public init(executorClosure: @escaping @Sendable (String, [String]) async throws -> String) { 32 | self.executorClosure = executorClosure 33 | } 34 | 35 | public init(dummy: [String: String]) { 36 | self.executorClosure = { command, arguments in 37 | let command = ([command] + arguments).joined(separator: " ") 38 | guard let result = dummy[command] else { 39 | Issue.record("Unexpected commend: \(command)") 40 | return "" 41 | } 42 | return result 43 | } 44 | } 45 | 46 | public func execute(command: String, _ arguments: [String]) async throws -> String { 47 | try await executorClosure(command, arguments) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/NestTestHelpers/MockFileSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ZIPFoundation 3 | import os 4 | import NestKit 5 | 6 | public final class MockFileSystem: FileSystem, Sendable { 7 | public var item: FileSystemItem { 8 | get { lockedItem.withLock { $0 } } 9 | set { lockedItem.withLock { $0 = newValue } } 10 | } 11 | public var symbolicLink: [URL: URL] { 12 | get { lockedSymbolicLink.withLock { $0 } } 13 | set { lockedSymbolicLink.withLock { $0 = newValue } } 14 | } 15 | 16 | public let homeDirectoryForCurrentUser: URL 17 | public let temporaryDirectory: URL 18 | 19 | private let lockedItem = OSAllocatedUnfairLock(initialState: FileSystemItem.directory(children: [:])) 20 | private let lockedSymbolicLink = OSAllocatedUnfairLock(initialState: [URL: URL]()) 21 | 22 | public init(homeDirectoryForCurrentUser: URL, temporaryDirectory: URL) { 23 | self.homeDirectoryForCurrentUser = homeDirectoryForCurrentUser 24 | self.temporaryDirectory = temporaryDirectory 25 | } 26 | 27 | public func createDirectory( 28 | at url: URL, 29 | withIntermediateDirectories createIntermediates: Bool, 30 | attributes: [FileAttributeKey: Any]? 31 | ) throws { 32 | lockedItem.withLock { item in 33 | if createIntermediates { 34 | let components = url.pathComponents 35 | var currentComponents: [String] = [] 36 | for component in components { 37 | currentComponents.append(component) 38 | if item.item(components: currentComponents) != nil { 39 | continue 40 | } 41 | item.update(item: .directory(children: [:]), at: currentComponents) 42 | } 43 | } else { 44 | item.update(item: .directory(children: [:]), at: url.pathComponents) 45 | } 46 | } 47 | } 48 | 49 | public func contentsOfDirectory(atPath path: String) throws -> [String] { 50 | try lockedItem.withLock { item in 51 | let originalURL = URL(fileURLWithPath: path) 52 | let url = symbolicLink[originalURL] ?? originalURL 53 | 54 | guard case .directory(let children) = item.item(components: url.pathComponents) else { 55 | throw MockFileSystemError.fileNotFound 56 | } 57 | return children.keys.map { $0 } 58 | } 59 | } 60 | 61 | public func removeItem(at originalURL: URL) throws { 62 | lockedItem.withLock { item in 63 | let url = symbolicLink[originalURL] ?? originalURL 64 | item.remove(at: url.pathComponents) 65 | symbolicLink.removeValue(forKey: originalURL) 66 | } 67 | } 68 | 69 | public func copyItem(at srcURL: URL, to dstURL: URL) throws { 70 | try lockedItem.withLock { item in 71 | let srcURL = self.symbolicLink[srcURL] ?? srcURL 72 | let dstURL = self.symbolicLink[dstURL] ?? dstURL 73 | guard let sourceItem = item.item(components: srcURL.pathComponents) else { 74 | throw MockFileSystemError.fileNotFound 75 | } 76 | item.update(item: sourceItem, at: dstURL.pathComponents) 77 | } 78 | } 79 | 80 | public func createSymbolicLink(at url: URL, withDestinationURL destURL: URL) throws { 81 | symbolicLink[url] = destURL 82 | } 83 | 84 | public func destinationOfSymbolicLink(atPath path: String) throws -> String { 85 | guard let result = symbolicLink[URL(fileURLWithPath: path)] else { 86 | throw MockFileSystemError.fileNotFound 87 | } 88 | return result.path() 89 | } 90 | 91 | public func fileExists(atPath path: String) -> Bool { 92 | lockedItem.withLock { item in 93 | let originalURL = URL(filePath: path) 94 | let url = symbolicLink[originalURL] ?? originalURL 95 | let components = url.pathComponents 96 | return item.item(components: components) != nil 97 | } 98 | } 99 | 100 | public func unzip( 101 | at sourceURL: URL, 102 | to destinationURL: URL, 103 | skipCRC32: Bool, 104 | allowUncontainedSymlinks: Bool, 105 | progress: Progress?, 106 | pathEncoding: String.Encoding? 107 | ) throws { 108 | let sourceURL = symbolicLink[sourceURL] ?? sourceURL 109 | let destinationURL = symbolicLink[destinationURL] ?? destinationURL 110 | try createDirectory(at: destinationURL, withIntermediateDirectories: true) 111 | 112 | guard let data = try? self.data(at: sourceURL) else { 113 | return 114 | } 115 | let archive = try Archive(data: data, accessMode: .update) 116 | 117 | try archive 118 | .filter { $0.type == .directory } 119 | .map(\.path) 120 | .forEach { directory in 121 | try createDirectory(at: destinationURL.appending(path: directory), withIntermediateDirectories: true) 122 | } 123 | 124 | try archive.filter { $0.type == .file } 125 | .forEach { entry in 126 | var data = Data() 127 | _ = try archive.extract(entry) { chunk in 128 | data += chunk 129 | } 130 | try write(data, to: destinationURL.appending(path: entry.path)) 131 | } 132 | } 133 | 134 | public func data(at url: URL) throws -> Data { 135 | try lockedItem.withLock { item in 136 | let url = symbolicLink[url] ?? url 137 | let components = url.pathComponents 138 | switch item.item(components: components) { 139 | case .file(let data): return data 140 | default: throw MockFileSystemError.fileNotFound 141 | } 142 | } 143 | } 144 | 145 | public func write(_ data: Data, to url: URL) throws { 146 | lockedItem.withLock { item in 147 | let components = url.pathComponents 148 | item.update(item: .file(data: data), at: components) 149 | } 150 | } 151 | } 152 | 153 | extension MockFileSystem { 154 | public func printStructure() { 155 | item.printStructure() 156 | for (sourceURL, destinationURL) in symbolicLink { 157 | print("\(sourceURL.path()) -> \(destinationURL.path())") 158 | } 159 | } 160 | 161 | public enum MockFileSystemError: Error { 162 | case fileNotFound 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/NestTestHelpers/MockHTTPClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import HTTPTypes 4 | import os 5 | import NestKit 6 | 7 | public final class MockHTTPClient: HTTPClient { 8 | let mockFileSystem: MockFileSystem 9 | let logger: Logging.Logger 10 | 11 | private let lockedDummyData = OSAllocatedUnfairLock<[URL: Data]>(initialState: [:]) 12 | public var dummyData: [URL: Data] { 13 | get { lockedDummyData.withLock { $0} } 14 | set { lockedDummyData.withLock { $0 = newValue } } 15 | } 16 | 17 | public init(mockFileSystem: MockFileSystem, logger: Logging.Logger = Logger(label: "Test")) { 18 | self.mockFileSystem = mockFileSystem 19 | self.logger = logger 20 | } 21 | 22 | public func data(for request: HTTPRequest) async throws -> (Data, HTTPTypes.HTTPResponse) { 23 | guard let url = request.url, 24 | let data = dummyData[url] else { 25 | logger.error("No dummy data for \(request.url!.absoluteString).") 26 | return (Data(), HTTPResponse(status: .notFound)) 27 | } 28 | return (data, HTTPResponse(status: .ok)) 29 | } 30 | 31 | public func download(for request: HTTPRequest) async throws -> (URL, HTTPTypes.HTTPResponse) { 32 | let localFilePath = mockFileSystem.temporaryDirectory.appending(path: UUID().uuidString) 33 | guard let url = request.url, 34 | let data = dummyData[url] else { 35 | return (localFilePath, HTTPResponse(status: .notFound)) 36 | } 37 | try mockFileSystem.write(data, to: localFilePath) 38 | return (localFilePath, HTTPResponse(status: .ok)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/nest/Arguments/ExcludedTarget+Arguments.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import NestKit 3 | 4 | extension ExcludedTarget: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | let split = argument.split(separator: "@") 7 | guard split.count == 1 || split.count == 2 else { return nil } 8 | self = .init(reference: String(split[0]), version: split.count == 2 ? String(split[1]) : nil) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/nest/Arguments/GitURL+Arguemtns.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestKit 4 | import UniformTypeIdentifiers 5 | 6 | extension GitURL: ExpressibleByArgument { 7 | public init?(argument: String) { 8 | guard let url = GitURL.parse(from: argument) else { return nil } 9 | self = url 10 | } 11 | } 12 | 13 | enum InstallTarget: ExpressibleByArgument { 14 | case git(GitURL) 15 | case artifactBundle(URL) 16 | 17 | init?(argument: String) { 18 | guard let url = URL(string: argument) else { return nil } 19 | 20 | if let utType = UTType(filenameExtension: url.pathExtension), utType.conforms(to: .zip) { 21 | self = .artifactBundle(url) 22 | } else if let gitURL = GitURL.parse(from: argument) { 23 | self = .git(gitURL) 24 | } else { 25 | return nil 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/nest/Arguments/GitVersion+Arguments.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import NestKit 3 | 4 | extension GitVersion: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | self = .tag(argument) 7 | } 8 | 9 | public var defaultValueDescription: String { 10 | description 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/nest/Arguments/RunCommandArgument.swift: -------------------------------------------------------------------------------- 1 | import NestKit 2 | import Logging 3 | 4 | struct SubCommandOfRunCommand: Sendable, Hashable { 5 | let repository: GitURL 6 | let arguments: [String] 7 | 8 | enum ParseError: Error { 9 | case emptyArguments 10 | case invalidFormat 11 | } 12 | 13 | init(arguments: [String]) throws(ParseError) { 14 | guard !arguments.isEmpty else { 15 | throw ParseError.emptyArguments 16 | } 17 | guard let repository = GitURL.parse(from: arguments[0]) else { 18 | throw ParseError.invalidFormat 19 | } 20 | 21 | self.repository = repository 22 | self.arguments = if arguments.count >= 2 { 23 | Array(arguments[1...]) 24 | } else { 25 | [] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/nest/Commands/BootstrapCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct BootstrapCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "bootstrap", 10 | abstract: "Install repositories based on a given nestfile." 11 | ) 12 | 13 | @Argument(help: "A nestfile written in yaml.") 14 | var nestfilePath: String 15 | 16 | @Flag(name: .shortAndLong, help: "Skip checksum validation for downloaded artifactbundles.") 17 | var skipChecksumValidation = false 18 | 19 | @Flag(name: .shortAndLong) 20 | var verbose: Bool = false 21 | 22 | mutating func run() async throws { 23 | let nestfile = try Nestfile.load(from: nestfilePath, fileSystem: FileManager.default) 24 | let (executableBinaryPreparer, artifactBundleManager, logger) = setUp(nestfile: nestfile) 25 | 26 | if nestfile.targets.contains(where: { $0.isDeprecatedZIP }) { 27 | logger.warning(""" 28 | ⚠️ The format `- {URL}` for targets is deprecated and will be removed in a future release. 29 | Please update to thew new format `- zipURL: {URL}`. 30 | """, metadata: .color(.yellow) 31 | ) 32 | } 33 | 34 | for targetInfo in nestfile.targets { 35 | let target: InstallTarget 36 | var version: GitVersion 37 | let checksumOption = ChecksumOption(isSkip: skipChecksumValidation, expectedChecksum: targetInfo.checksum, logger: logger) 38 | 39 | switch (targetInfo.resolveInstallTarget(), targetInfo.resolveVersion()) { 40 | case (.failure(let error), _): 41 | logger.error("Invalid input: \(error.contents)", metadata: .color(.red)) 42 | return 43 | case (.success(let installTarget), let resolvedVersion): 44 | target = installTarget 45 | version = if let resolvedVersion { .tag(resolvedVersion) } 46 | else { .latestRelease } 47 | } 48 | 49 | let executableBinaries: [ExecutableBinary] 50 | switch target { 51 | case .git(let gitURL): 52 | let versionString = version == .latestRelease ? "" : "(\(version.description)) " 53 | logger.info("🔎 Found \(gitURL.repositoryName) \(versionString)") 54 | executableBinaries = try await executableBinaryPreparer.fetchOrBuildBinariesFromGitRepository( 55 | at: gitURL, 56 | version: version, 57 | artifactBundleZipFileName: targetInfo.assetName, 58 | checksum: checksumOption 59 | ) 60 | case .artifactBundle(let url): 61 | logger.info("🔎 Start \(url.absoluteString)") 62 | executableBinaries = try await executableBinaryPreparer.fetchArtifactBundle(at: url, checksum: checksumOption) 63 | } 64 | 65 | for binary in executableBinaries { 66 | try artifactBundleManager.install(binary) 67 | logger.info("🪺 Success to install \(binary.commandName).", metadata: .color(.green)) 68 | } 69 | } 70 | } 71 | } 72 | 73 | extension Nestfile.Target { 74 | struct ParseError: Error { 75 | let contents: String 76 | } 77 | 78 | func resolveInstallTarget() -> Result { 79 | switch self { 80 | case .repository(let repository): 81 | guard let parsedTarget = InstallTarget(argument: repository.reference) else { 82 | return .failure(ParseError(contents: repository.reference)) 83 | } 84 | return .success(parsedTarget) 85 | case .zip(let zipURL): 86 | guard let parsedTarget = InstallTarget(argument: zipURL.zipURL) else { 87 | return .failure(ParseError(contents: zipURL.zipURL)) 88 | } 89 | return .success(parsedTarget) 90 | case .deprecatedZIP(let zipURL): 91 | guard let parsedTarget = InstallTarget(argument: zipURL.url) else { 92 | return .failure(ParseError(contents: zipURL.url)) 93 | } 94 | return .success(parsedTarget) 95 | } 96 | } 97 | 98 | func resolveVersion() -> String? { 99 | switch self { 100 | case .repository(let repository): 101 | return repository.version 102 | case .zip, .deprecatedZIP: 103 | return nil 104 | } 105 | } 106 | } 107 | 108 | extension BootstrapCommand { 109 | private func setUp(nestfile: Nestfile) -> ( 110 | ExecutableBinaryPreparer, 111 | ArtifactBundleManager, 112 | Logger 113 | ) { 114 | LoggingSystem.bootstrap() 115 | let configuration = Configuration.make( 116 | nestPath: nestfile.nestPath ?? ProcessInfo.processInfo.nestPath, 117 | registryTokenEnvironmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:], 118 | logLevel: verbose ? .trace : .info 119 | ) 120 | 121 | return ( 122 | configuration.executableBinaryPreparer, 123 | configuration.artifactBundleManager, 124 | configuration.logger 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/nest/Commands/GenerateNestfileCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct GenerateNestfileCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "generate-nestfile", 10 | abstract: "Generates a sample nestfile into the current directory." 11 | ) 12 | 13 | @Flag(name: .shortAndLong) 14 | var verbose: Bool = false 15 | 16 | @MainActor mutating func run() async throws { 17 | let logger = setUp() 18 | let url = URL(filePath: "./nestfile.yaml") 19 | if FileManager.default.fileExists(atPath: url.path()) { 20 | logger.error("nestfile exists in the current directory.", metadata: .color(.red)) 21 | return 22 | } 23 | try templateString.write(to: url, atomically: true, encoding: .utf8) 24 | logger.error("📄 nestfile was generated.") 25 | } 26 | } 27 | 28 | extension GenerateNestfileCommand { 29 | private func setUp() -> Logger { 30 | LoggingSystem.bootstrap() 31 | let configuration = Configuration.make( 32 | nestPath: ProcessInfo.processInfo.nestPath, 33 | logLevel: verbose ? .trace : .info 34 | ) 35 | 36 | return configuration.logger 37 | } 38 | } 39 | 40 | 41 | let templateString = """ 42 | nestPath: ./.nest 43 | targets: 44 | - reference: realm/SwiftLint 45 | """ 46 | -------------------------------------------------------------------------------- /Sources/nest/Commands/InstallCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct InstallCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "install", 10 | abstract: "Install a repository" 11 | ) 12 | 13 | @Argument(help: """ 14 | A git repository or a URL of an artifactbunlde you want to install. (e.g., `owner/repository`, `https://github.com/...`, and `https://examaple.com/../foo.artifactbundle.zip`) 15 | """) 16 | var target: InstallTarget 17 | 18 | @Argument 19 | var version: GitVersion = .latestRelease 20 | 21 | @Flag(name: .shortAndLong) 22 | var verbose: Bool = false 23 | 24 | mutating func run() async throws { 25 | let (executableBinaryPreparer, nestDirectory, artifactBundleManager, logger) = setUp() 26 | do { 27 | 28 | let executableBinaries = switch target { 29 | case .git(let gitURL): 30 | try await executableBinaryPreparer.fetchOrBuildBinariesFromGitRepository( 31 | at: gitURL, 32 | version: version, 33 | artifactBundleZipFileName: nil, 34 | checksum: .skip 35 | ) 36 | case .artifactBundle(let url): 37 | try await executableBinaryPreparer.fetchArtifactBundle(at: url, checksum: .skip) 38 | } 39 | 40 | for binary in executableBinaries { 41 | try artifactBundleManager.install(binary) 42 | logger.info("🪺 Success to install \(binary.commandName).", metadata: .color(.green)) 43 | } 44 | 45 | let binDirectory = nestDirectory.bin.path() 46 | let path = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map { String($0) } ?? [] 47 | if ProcessInfo.processInfo.nestPath?.isEmpty ?? true, 48 | !path.contains(binDirectory) { 49 | logger.warning("\(binDirectory) is not added to $PATH.", metadata: .color(.yellow)) 50 | } 51 | } catch { 52 | logger.error(error) 53 | Foundation.exit(1) 54 | } 55 | } 56 | } 57 | 58 | extension InstallCommand { 59 | private func setUp() -> ( 60 | ExecutableBinaryPreparer, 61 | NestDirectory, 62 | ArtifactBundleManager, 63 | Logger 64 | ) { 65 | LoggingSystem.bootstrap() 66 | let configuration = Configuration.make( 67 | nestPath: ProcessInfo.processInfo.nestPath, 68 | logLevel: verbose ? .trace : .info 69 | ) 70 | 71 | return ( 72 | configuration.executableBinaryPreparer, 73 | configuration.nestDirectory, 74 | configuration.artifactBundleManager, 75 | configuration.logger 76 | ) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/nest/Commands/ListCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct ListCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "list", 10 | abstract: "Show all installed binaries " 11 | ) 12 | 13 | @Flag(name: .shortAndLong, help: "Show a source of a binary.") 14 | var source: Bool = false 15 | 16 | @Flag(name: .shortAndLong) 17 | var verbose: Bool = false 18 | 19 | @MainActor mutating func run() async throws { 20 | let (artifactBundleManager, logger) = setUp() 21 | 22 | let installedCommands = artifactBundleManager.list() 23 | for (name, commands) in installedCommands { 24 | logger.info("\(name)") 25 | for command in commands { 26 | let isLinked = artifactBundleManager.isLinked(name: name, commend: command) 27 | logger.info(" \(command.version) \(source ? command.source : "") \(isLinked ? "(Selected)".green : "")") 28 | } 29 | } 30 | } 31 | } 32 | 33 | extension ListCommand { 34 | private func setUp() -> ( 35 | ArtifactBundleManager, 36 | Logger 37 | ) { 38 | LoggingSystem.bootstrap() 39 | let configuration = Configuration.make( 40 | nestPath: ProcessInfo.processInfo.nestPath, 41 | logLevel: verbose ? .trace : .info 42 | ) 43 | 44 | return ( 45 | configuration.artifactBundleManager, 46 | configuration.logger 47 | ) 48 | } 49 | } 50 | 51 | extension NestInfo.Command { 52 | var source: String { 53 | switch manufacturer { 54 | case .artifactBundle(let sourceInfo): 55 | sourceInfo.repository?.reference.stringURL ?? sourceInfo.zipURL.absoluteString 56 | case .localBuild(let repository): 57 | repository.reference.stringURL 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/nest/Commands/ResolveNestfileCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import AsyncOperations 3 | import Foundation 4 | import NestCLI 5 | import NestKit 6 | import Logging 7 | 8 | struct ResolveNestfileCommand: AsyncParsableCommand { 9 | static let configuration = CommandConfiguration( 10 | commandName: "resolve-nestfile", 11 | abstract: "Overwrite the nestfile with the latest versions if a version is not specified." 12 | ) 13 | 14 | @Argument(help: "A nestfile written in yaml.") 15 | var nestfilePath: String 16 | 17 | @Flag(name: .shortAndLong) 18 | var verbose: Bool = false 19 | 20 | mutating func run() async throws { 21 | let nestfile = try Nestfile.load(from: nestfilePath, fileSystem: FileManager.default) 22 | let (controller, fileSystem, logger) = setUp(nestfile: nestfile) 23 | let updatedNestfile = try await controller.resolve(nestfile) 24 | try updatedNestfile.write(to: nestfilePath, fileSystem: fileSystem) 25 | logger.info("✨ \(URL(filePath: nestfilePath).lastPathComponent) is Updated") 26 | } 27 | } 28 | 29 | extension ResolveNestfileCommand { 30 | private func setUp(nestfile: Nestfile) -> (NestfileController, any FileSystem, Logger) { 31 | LoggingSystem.bootstrap() 32 | let configuration = Configuration.make( 33 | nestPath: nestfile.nestPath ?? ProcessInfo.processInfo.nestPath, 34 | registryTokenEnvironmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:], 35 | logLevel: verbose ? .trace : .info 36 | ) 37 | let controller = NestfileController( 38 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 39 | httpClient: configuration.httpClient, 40 | registryConfigs: RegistryConfigs(github: GitHubRegistryConfigs.resolve(environmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:])), 41 | logger: configuration.logger 42 | ), 43 | fileSystem: configuration.fileSystem, 44 | fileDownloader: configuration.fileDownloader, 45 | checksumCalculator: SwiftChecksumCalculator(swift: SwiftCommand( 46 | executor: NestProcessExecutor(logger: configuration.logger) 47 | )) 48 | ) 49 | return (controller, configuration.fileSystem, configuration.logger) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/nest/Commands/RunCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestKit 4 | import NestCLI 5 | import Logging 6 | 7 | struct RunCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "run", 10 | abstract: "Run executable file on a given nestfile. If not found, it will attempt to install." 11 | ) 12 | 13 | @Flag(name: .shortAndLong) 14 | var verbose = false 15 | 16 | @Flag(help: "Will not perform installation.") 17 | var noInstall = false 18 | 19 | @Option(help: "A path to nestfile", completion: .file(extensions: ["yaml"])) 20 | var nestfilePath = "nestfile.yaml" 21 | 22 | @Argument(parsing: .captureForPassthrough) 23 | var arguments: [String] 24 | 25 | mutating func run() async throws { 26 | if arguments.first == "--help" { 27 | let helpMessage = Self.helpMessage(for: Self.self) 28 | print(helpMessage) 29 | return 30 | } 31 | 32 | let nestfile: Nestfile 33 | do { 34 | nestfile = try Nestfile.load(from: nestfilePath, fileSystem: FileManager.default) 35 | } catch { 36 | print("Nestfile not found at \(nestfilePath)".red) 37 | return 38 | } 39 | 40 | let (nestfileController, executableBinaryPreparer, nestDirectory, logger) = setUp(nestfile: nestfile) 41 | 42 | let subcommand: SubCommandOfRunCommand 43 | do { 44 | subcommand = try SubCommandOfRunCommand(arguments: arguments) 45 | } catch .emptyArguments { 46 | logger.error("`owner/repository` is not specified.", metadata: .color(.red)) 47 | return 48 | } catch .invalidFormat { 49 | logger.error("Invalid format: \"\(arguments[0])\", expected owner/repository", metadata: .color(.red)) 50 | return 51 | } 52 | 53 | guard let target = nestfileController.target(matchingTo: subcommand.repository, in: nestfile), 54 | let expectedVersion = target.version 55 | else { 56 | // While we could execute with the latest version, the bootstrap subcommand serves that purpose. 57 | // Therefore, we return an error when no version is specified. 58 | logger.error("Failed to find an expected version for \"\(arguments[0])\" in nestfile", metadata: .color(.red)) 59 | return 60 | } 61 | 62 | let version = GitVersion.tag(expectedVersion) 63 | let executables: [ExecutableBinary] 64 | let installedBinaries = executableBinaryPreparer.resolveInstalledExecutableBinariesFromNestInfo(for: subcommand.repository, version: version) 65 | if !installedBinaries.isEmpty { 66 | executables = installedBinaries 67 | } else if noInstall { 68 | logger.error("The executable binary is not installed yet. Please try without the --no-install option or run the bootstrap command.", metadata: .color(.red)) 69 | return 70 | } else { 71 | logger.info("Install executable binaries because they are not installed.") 72 | try await executableBinaryPreparer.installBinaries( 73 | gitURL: subcommand.repository, 74 | version: version, 75 | assetName: target.assetName, 76 | checksumOption: ChecksumOption(expectedChecksum: target.checksum, logger: logger) 77 | ) 78 | executables = executableBinaryPreparer.resolveInstalledExecutableBinariesFromNestInfo(for: subcommand.repository, version: version) 79 | } 80 | 81 | guard !executables.isEmpty else { 82 | logger.error("No executable binary found.") 83 | return 84 | } 85 | 86 | let binaryRelativePath = executables[0].binaryPath // FIXME: Needs to address multiple commands in the same artifact bundle. 87 | _ = try? await NestProcessExecutor(logger: logger, logLevel: .info) 88 | .execute( 89 | command: nestDirectory.rootDirectory.appending(path: binaryRelativePath.path(percentEncoded: false)).path(percentEncoded: false), 90 | subcommand.arguments 91 | ) 92 | } 93 | } 94 | 95 | extension RunCommand { 96 | private func setUp(nestfile: Nestfile) -> ( 97 | NestfileController, 98 | ExecutableBinaryPreparer, 99 | NestDirectory, 100 | Logger 101 | ) { 102 | LoggingSystem.bootstrap() 103 | let configuration = Configuration.make( 104 | nestPath: nestfile.nestPath ?? ProcessInfo.processInfo.nestPath, 105 | registryTokenEnvironmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:], 106 | logLevel: verbose ? .trace : .info 107 | ) 108 | 109 | let controller = NestfileController( 110 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 111 | httpClient: configuration.httpClient, 112 | registryConfigs: RegistryConfigs(github: GitHubRegistryConfigs.resolve(environmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:])), 113 | logger: configuration.logger 114 | ), 115 | fileSystem: configuration.fileSystem, 116 | fileDownloader: configuration.fileDownloader, 117 | checksumCalculator: SwiftChecksumCalculator(swift: SwiftCommand( 118 | executor: NestProcessExecutor(logger: configuration.logger) 119 | )) 120 | ) 121 | 122 | return ( 123 | controller, 124 | configuration.executableBinaryPreparer, 125 | configuration.nestDirectory, 126 | configuration.logger 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/nest/Commands/SwitchCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct SwitchCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "switch", 10 | abstract: "Switch a version of an installed command." 11 | ) 12 | 13 | @Argument(help: "A command name") 14 | var commandName: String 15 | 16 | @Argument 17 | var version: String? 18 | 19 | @Flag(name: .shortAndLong) 20 | var verbose: Bool = false 21 | 22 | mutating func run() async throws { 23 | let (nestDirectory, artifactBundleManager, logger) = setUp() 24 | 25 | guard let commands = artifactBundleManager.list()[commandName] else { 26 | logger.error("🪹 \(commandName) doesn't exist.", metadata: .color(.red)) 27 | return 28 | } 29 | let candidates = commands.filter { $0.version == version || version == nil } 30 | 31 | do { 32 | if candidates.isEmpty, 33 | let version { 34 | logger.error("🪹 \(commandName) (\(version)) doesn't exist.", metadata: .color(.red)) 35 | } else if candidates.count == 1 { 36 | try switchCommand(candidates[0], nestDirectory: nestDirectory, artifactBundleManager: artifactBundleManager, logger: logger) 37 | } 38 | else { 39 | let options = candidates.map { candidate in 40 | let isLinked = artifactBundleManager.isLinked(name: commandName, commend: candidate) 41 | return "\(candidate.version) (\(candidate.source)) \(isLinked ? "(Selected)".green : "")"} 42 | guard let selectedIndex = CLIUtil.getUserChoice(from: options) else { 43 | logger.error("Unknown error") 44 | return 45 | } 46 | let command = candidates[selectedIndex] 47 | try switchCommand(command, nestDirectory: nestDirectory, artifactBundleManager: artifactBundleManager, logger: logger) 48 | } 49 | } catch { 50 | logger.error(error) 51 | Foundation.exit(1) 52 | } 53 | } 54 | 55 | private func switchCommand( 56 | _ command: NestInfo.Command, 57 | nestDirectory: NestDirectory, 58 | artifactBundleManager: ArtifactBundleManager, 59 | logger: Logger 60 | ) throws { 61 | let binaryInfo = ExecutableBinary( 62 | commandName: commandName, 63 | binaryPath: nestDirectory.url(command.binaryPath), 64 | version: command.version, 65 | manufacturer: command.manufacturer 66 | ) 67 | try artifactBundleManager.link(binaryInfo) 68 | logger.info("🪺 \(binaryInfo.commandName) (\(binaryInfo.version)) is installed.") 69 | } 70 | } 71 | 72 | extension SwitchCommand { 73 | private func setUp() -> ( 74 | NestDirectory, 75 | ArtifactBundleManager, 76 | Logger 77 | ) { 78 | LoggingSystem.bootstrap() 79 | let configuration = Configuration.make( 80 | nestPath: ProcessInfo.processInfo.nestPath, 81 | logLevel: verbose ? .trace : .info 82 | ) 83 | 84 | return ( 85 | configuration.nestDirectory, 86 | configuration.artifactBundleManager, 87 | configuration.logger 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/nest/Commands/UninstallCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestCLI 4 | import NestKit 5 | import Logging 6 | 7 | struct UninstallCommand: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "uninstall", 10 | abstract: "Uninstall a repository" 11 | ) 12 | 13 | @Argument(help: "A command name you want to uninstall.") 14 | var commandName: String 15 | 16 | @Argument(help: "A version you want to uninstall") 17 | var version: String? 18 | 19 | @Flag(name: .shortAndLong) 20 | var verbose: Bool = false 21 | 22 | mutating func run() async throws { 23 | let (artifactBundleManager, logger) = setUp() 24 | 25 | let info = artifactBundleManager.list() 26 | 27 | let targetCommand = info[commandName, default: []].filter { command in 28 | command.version == version || version == nil 29 | } 30 | 31 | guard !targetCommand.isEmpty else { 32 | let message: Logger.Message = 33 | if let version { 34 | "🪹 \(commandName) (\(version)) doesn't exist." 35 | } else { 36 | "🪹 \(commandName) doesn't exist." 37 | } 38 | logger.error(message, metadata: .color(.red)) 39 | Foundation.exit(1) 40 | } 41 | 42 | for command in targetCommand { 43 | try artifactBundleManager.uninstall(command: commandName, version: command.version) 44 | logger.info("🗑️ \(commandName) \(command.version) is uninstalled.") 45 | } 46 | } 47 | } 48 | 49 | extension UninstallCommand { 50 | private func setUp() -> ( 51 | ArtifactBundleManager, 52 | Logger 53 | ) { 54 | LoggingSystem.bootstrap() 55 | let configuration = Configuration.make( 56 | nestPath: ProcessInfo.processInfo.nestPath, 57 | logLevel: verbose ? .trace : .info 58 | ) 59 | 60 | return ( 61 | configuration.artifactBundleManager, 62 | configuration.logger 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/nest/Commands/UpdateNestfileCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import AsyncOperations 3 | import Foundation 4 | import NestCLI 5 | import NestKit 6 | import Logging 7 | 8 | struct UpdateNestfileCommand: AsyncParsableCommand { 9 | static let configuration = CommandConfiguration( 10 | commandName: "update-nestfile", 11 | abstract: "Overwrite the nestfile with the latest versions." 12 | ) 13 | 14 | @Argument(help: "A nestfile written in yaml.") 15 | var nestfilePath: String 16 | 17 | @Option(parsing: .upToNextOption, help: "Exclude by repository or version when using reference-only.\n(ex. --excludes owner/repo@0.0.1 owner/repo@0.0.2)") 18 | var excludes: [ExcludedTarget] = [] 19 | 20 | @Flag(name: .shortAndLong) 21 | var verbose: Bool = false 22 | 23 | mutating func run() async throws { 24 | let nestfile = try Nestfile.load(from: nestfilePath, fileSystem: FileManager.default) 25 | let (controller, fileSystem, logger) = setUp(nestfile: nestfile) 26 | let updatedNestfile = try await controller.update(nestfile, excludedTargets: excludes) 27 | try updatedNestfile.write(to: nestfilePath, fileSystem: fileSystem) 28 | logger.info("✨ \(URL(filePath: nestfilePath).lastPathComponent) is Updated") 29 | } 30 | } 31 | 32 | extension UpdateNestfileCommand { 33 | private func setUp(nestfile: Nestfile) -> (NestfileController, any FileSystem, Logger) { 34 | LoggingSystem.bootstrap() 35 | let configuration = Configuration.make( 36 | nestPath: nestfile.nestPath ?? ProcessInfo.processInfo.nestPath, 37 | registryTokenEnvironmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:], 38 | logLevel: verbose ? .trace : .info 39 | ) 40 | let controller = NestfileController( 41 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 42 | httpClient: configuration.httpClient, 43 | registryConfigs: RegistryConfigs(github: GitHubRegistryConfigs.resolve(environmentVariableNames: nestfile.registries?.githubServerTokenEnvironmentVariableNames ?? [:])), 44 | logger: configuration.logger 45 | ), 46 | fileSystem: configuration.fileSystem, 47 | fileDownloader: configuration.fileDownloader, 48 | checksumCalculator: SwiftChecksumCalculator(swift: SwiftCommand( 49 | executor: NestProcessExecutor(logger: configuration.logger) 50 | )) 51 | ) 52 | return (controller, configuration.fileSystem, configuration.logger) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/nest/Utils/CLIUtil.swift: -------------------------------------------------------------------------------- 1 | enum CLIUtil { 2 | static func getUserChoice(from options: [String]) -> Int? { 3 | for (index, option) in options.enumerated() { 4 | print("\(index + 1)) \(option)") 5 | } 6 | 7 | print("Enter the number > ", terminator: "") 8 | 9 | while let input = readLine() { 10 | if let choice = Int(input), choice >= 1 && choice <= options.count { 11 | return choice - 1 12 | } 13 | print("Invalid input, please enter the number.") 14 | print("Enter the number > ", terminator: "") 15 | } 16 | return nil 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/nest/Utils/Configuration+Dependencies.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import Logging 4 | import NestCLI 5 | import NestKit 6 | 7 | extension Configuration { 8 | 9 | var executableBinaryPreparer: ExecutableBinaryPreparer { 10 | ExecutableBinaryPreparer( 11 | artifactBundleFetcher: artifactBundleFetcher, 12 | swiftPackageBuilder: swiftPackageBuilder, 13 | nestInfoController: NestInfoController(directory: nestDirectory, fileSystem: fileSystem), 14 | artifactBundleManager: ArtifactBundleManager(fileSystem: fileSystem, directory: nestDirectory), 15 | logger: logger 16 | ) 17 | } 18 | 19 | private var artifactBundleFetcher: ArtifactBundleFetcher { 20 | ArtifactBundleFetcher( 21 | workingDirectory: workingDirectory, 22 | executorBuilder: NestProcessExecutorBuilder(logger: logger), 23 | fileSystem: fileSystem, 24 | fileDownloader: NestFileDownloader(httpClient: httpClient), 25 | nestInfoController: NestInfoController(directory: nestDirectory, fileSystem: fileSystem), 26 | assetRegistryClientBuilder: assetRegistryClientBuilder, 27 | logger: logger 28 | ) 29 | } 30 | 31 | private var swiftPackageBuilder: SwiftPackageBuilder { 32 | SwiftPackageBuilder( 33 | workingDirectory: workingDirectory, 34 | executorBuilder: NestProcessExecutorBuilder(logger: logger), 35 | fileSystem: fileSystem, 36 | nestInfoController: NestInfoController(directory: nestDirectory, fileSystem: fileSystem), 37 | assetRegistryClientBuilder: assetRegistryClientBuilder, 38 | logger: logger 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/nest/Utils/NestLogHandler.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | import Rainbow 3 | 4 | struct NestLogHandler: LogHandler { 5 | var logLevel: Logger.Level = .info 6 | var metadata = Logger.Metadata() 7 | 8 | subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { 9 | get { metadata[metadataKey] } 10 | set { metadata[metadataKey] = newValue } 11 | } 12 | 13 | func log(level: Logger.Level, 14 | message: Logger.Message, 15 | metadata: Logger.Metadata?, 16 | source: String, 17 | file: String, 18 | function: String, 19 | line: UInt) { 20 | let color: NamedColor? 21 | if let metadata = metadata, 22 | let rawColorString = metadata["color"], 23 | let colorCode = UInt8(rawColorString.description), 24 | let namedColor = NamedColor(rawValue: colorCode) { 25 | color = namedColor 26 | } else { 27 | color = nil 28 | } 29 | if let color = color { 30 | print(message.description.applyingColor(color)) 31 | } else { 32 | print(message.description) 33 | } 34 | } 35 | } 36 | 37 | extension LoggingSystem { 38 | public static func bootstrap() { 39 | self.bootstrap { _ in 40 | NestLogHandler() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/nest/nest.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import NestKit 4 | import NestCLI 5 | import Logging 6 | 7 | @main 8 | struct Nest: AsyncParsableCommand { 9 | static let configuration = CommandConfiguration( 10 | commandName: "nest", 11 | subcommands: [ 12 | InstallCommand.self, 13 | UninstallCommand.self, 14 | ListCommand.self, 15 | SwitchCommand.self, 16 | BootstrapCommand.self, 17 | RunCommand.self, 18 | GenerateNestfileCommand.self, 19 | UpdateNestfileCommand.self, 20 | ResolveNestfileCommand.self 21 | ] 22 | ) 23 | } 24 | 25 | extension Configuration { 26 | static func make( 27 | nestPath: String?, 28 | registryTokenEnvironmentVariableNames: [Nestfile.RegistryConfigs.GitHubHost: String] = [:], 29 | logLevel: Logger.Level, 30 | httpClient: some HTTPClient = URLSession.shared, 31 | fileSystem: some FileSystem = FileManager.default 32 | ) -> Configuration { 33 | let nestDirectory = NestDirectory( 34 | rootDirectory: nestPath.map { URL(filePath: $0) } ?? fileSystem.defaultNestPath 35 | ) 36 | 37 | var logger = Logger(label: "com.github.mtj0928.nest") 38 | logger.logLevel = logLevel 39 | logger.debug("NEST_PATH: \(nestDirectory.rootDirectory.path()).") 40 | 41 | let githubRegistryConfigs = GitHubRegistryConfigs.resolve(environmentVariableNames: registryTokenEnvironmentVariableNames) 42 | 43 | let assetRegistryClientBuilder = AssetRegistryClientBuilder( 44 | httpClient: httpClient, 45 | registryConfigs: RegistryConfigs(github: githubRegistryConfigs), 46 | logger: logger 47 | ) 48 | 49 | return Configuration( 50 | httpClient: httpClient, 51 | fileSystem: fileSystem, 52 | fileDownloader: NestFileDownloader(httpClient: httpClient), 53 | workingDirectory: fileSystem.temporaryDirectory.appending(path: "nest"), 54 | assetRegistryClientBuilder: assetRegistryClientBuilder, 55 | nestDirectory: nestDirectory, 56 | artifactBundleManager: ArtifactBundleManager(fileSystem: fileSystem, directory: nestDirectory), 57 | logger: logger 58 | ) 59 | } 60 | } 61 | 62 | extension FileSystem { 63 | var defaultNestPath: URL { 64 | homeDirectoryForCurrentUser.appending(component: ".nest") 65 | } 66 | } 67 | 68 | extension ProcessInfo { 69 | var nestPath: String? { 70 | environment["NEST_PATH"] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/NestCLITests/ArtfactBundleFetcherTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import NestCLI 3 | import NestKit 4 | import Foundation 5 | import Logging 6 | import NestTestHelpers 7 | 8 | struct ArtfactBundleFetcherTests { 9 | let logger = Logger(label: "test") 10 | let nestDirectory = NestDirectory(rootDirectory: URL(filePath: "/User/.nest")) 11 | let executorBuilder = MockExecutorBuilder(dummy: [ 12 | "/usr/bin/which swift": "/usr/bin/swift", 13 | "/usr/bin/swift -print-target-info": """ 14 | { "target": { "unversionedTriple": "arm64-apple-macosx" } } 15 | """, 16 | "/usr/bin/swift package compute-checksum /tmp/artifactbundle.zip": "aaa", 17 | "/usr/bin/swift package compute-checksum /tmp/repo.zip": "aaa" 18 | ]) 19 | let fileSystem = MockFileSystem( 20 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 21 | temporaryDirectory: URL(filePath: "/tmp") 22 | ) 23 | 24 | init() { 25 | fileSystem.item = [ 26 | "/": [ 27 | "User": [:], 28 | "tmp": [:] 29 | ] 30 | ] 31 | } 32 | 33 | @Test(arguments: [ 34 | (artifactBundlePath: artifactBundlePath, expectedBinaryPath: URL(filePath: "/tmp/nest/artifactbundle/foo.artifactbundle/foo-1.0.0-macosx/bin/foo")), 35 | (artifactBundlePath: withoutArtifactBundleFolderPath, expectedBinaryPath: URL(filePath: "/tmp/nest/artifactbundle/foo-1.0.0-macosx/bin/foo")) 36 | ]) 37 | func fetchArtifactBundleWithZIPURL(artifactBundlePath: URL, expectedBinaryPath: URL) async throws { 38 | let workingDirectory = URL(filePath: "/tmp/nest") 39 | 40 | let zipURL = try #require(URL(string: "https://example.com/artifactbundle.zip")) 41 | let httpClient = MockHTTPClient(mockFileSystem: fileSystem) 42 | httpClient.dummyData = [zipURL: try Data(contentsOf: artifactBundlePath)] 43 | 44 | let fileDownloader = NestFileDownloader(httpClient: httpClient) 45 | let fetcher = ArtifactBundleFetcher( 46 | workingDirectory: workingDirectory, 47 | executorBuilder: executorBuilder, 48 | fileSystem: fileSystem, 49 | fileDownloader: fileDownloader, 50 | nestInfoController: NestInfoController(directory: nestDirectory, fileSystem: fileSystem), 51 | assetRegistryClientBuilder: AssetRegistryClientBuilder(httpClient: httpClient, registryConfigs: nil, logger: logger), 52 | logger: logger 53 | ) 54 | let result = try await fetcher.downloadArtifactBundle(url: zipURL, checksum: .skip) 55 | #expect(result == [ExecutableBinary( 56 | commandName: "foo", 57 | binaryPath: expectedBinaryPath, 58 | version: "1.0.0", 59 | manufacturer: .artifactBundle(sourceInfo: ArtifactBundleSourceInfo(zipURL: zipURL, repository: nil)) 60 | )]) 61 | } 62 | 63 | @Test(arguments: [ 64 | (artifactBundlePath: artifactBundlePath, expectedBinaryPath: URL(filePath: "/tmp/nest/repo/foo.artifactbundle/foo-1.0.0-macosx/bin/foo")), 65 | (artifactBundlePath: withoutArtifactBundleFolderPath, expectedBinaryPath: URL(filePath: "/tmp/nest/repo/foo-1.0.0-macosx/bin/foo")) 66 | ]) 67 | func fetchArtifactBundleFromGitRepository(artifactBundlePath: URL, expectedBinaryPath: URL) async throws { 68 | let workingDirectory = URL(filePath: "/tmp/nest") 69 | 70 | let zipURL = try #require(URL(string: "https://example.com/artifactbundle.zip")) 71 | let httpClient = MockHTTPClient(mockFileSystem: fileSystem) 72 | let apiResponse = try #require(""" 73 | { 74 | "tag_name": "1.0.0", 75 | "assets": [ 76 | { 77 | "name": "artifactbundle.zip", 78 | "browser_download_url": "\(zipURL.absoluteString)" 79 | } 80 | ] 81 | } 82 | """.data(using: .utf8)) 83 | httpClient.dummyData = try [ 84 | zipURL: Data(contentsOf: artifactBundlePath), 85 | #require(URL(string: "https://api.github.com/repos/owner/repo/releases/tags/1.0.0")): apiResponse 86 | ] 87 | 88 | let fileDownloader = NestFileDownloader(httpClient: httpClient) 89 | let fetcher = ArtifactBundleFetcher( 90 | workingDirectory: workingDirectory, 91 | executorBuilder: executorBuilder, 92 | fileSystem: fileSystem, 93 | fileDownloader: fileDownloader, 94 | nestInfoController: NestInfoController(directory: nestDirectory, fileSystem: fileSystem), 95 | assetRegistryClientBuilder: AssetRegistryClientBuilder(httpClient: httpClient, registryConfigs: nil, logger: logger), 96 | logger: logger 97 | ) 98 | let gitRepositoryURL = try #require(URL(string: "https://github.com/owner/repo")) 99 | let result = try await fetcher.fetchArtifactBundleFromGitRepository( 100 | for: gitRepositoryURL, 101 | version: .tag("1.0.0"), 102 | artifactBundleZipFileName: nil, 103 | checksum: .printActual { checksum in 104 | #expect(checksum == "aaa") 105 | } 106 | ) 107 | let expected = [ExecutableBinary( 108 | commandName: "foo", 109 | binaryPath: expectedBinaryPath, 110 | version: "1.0.0", 111 | manufacturer: .artifactBundle(sourceInfo: ArtifactBundleSourceInfo( 112 | zipURL: zipURL, 113 | repository: Repository(reference: .url(gitRepositoryURL), version: "1.0.0") 114 | )) 115 | )] 116 | #expect(result == expected) 117 | } 118 | } 119 | 120 | let fixturePath = URL(fileURLWithPath: #filePath) 121 | .deletingLastPathComponent() 122 | .appendingPathComponent("Resources") 123 | .appendingPathComponent("Fixtures") 124 | 125 | let artifactBundlePath = fixturePath.appendingPathComponent("foo.artifactbundle.zip") 126 | let withoutArtifactBundleFolderPath = fixturePath.appendingPathComponent("without.artifactbundle.folder.artifactbundle.zip") 127 | -------------------------------------------------------------------------------- /Tests/NestCLITests/NestfileControllerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import NestCLI 4 | @testable import NestKit 5 | import NestTestHelpers 6 | import Testing 7 | 8 | struct NestfileControllerTests { 9 | let fileSystem: MockFileSystem 10 | let httpClient: MockHTTPClient 11 | let processExecutor = MockProcessExecutor(dummy: [ 12 | "/usr/bin/which swift": "/usr/bin/swift", 13 | "/usr/bin/swift package compute-checksum /tmp/foo.artifacatbundle.zip": "aaa", 14 | ]) 15 | 16 | init() { 17 | fileSystem = MockFileSystem( 18 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 19 | temporaryDirectory: URL(filePath: "/tmp") 20 | ) 21 | httpClient = MockHTTPClient(mockFileSystem: fileSystem) 22 | fileSystem.item = [ 23 | "/": [ 24 | "User": .directory, 25 | "tmp": .directory 26 | ] 27 | ] 28 | } 29 | 30 | @Test 31 | func fetchTarget() async throws { 32 | let zipFileURL = try #require(URL(string: "https://example.com/foo.artifacatbundle.zip")) 33 | let controller = NestfileController( 34 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 35 | httpClient: httpClient, 36 | registryConfigs: nil, 37 | logger: Logger(label: "Test") 38 | ), 39 | fileSystem: fileSystem, 40 | fileDownloader: NestFileDownloader(httpClient: httpClient), 41 | checksumCalculator: SwiftChecksumCalculator(processExecutor: processExecutor) 42 | ) 43 | let repositoryTarget = Nestfile.Target.repository( 44 | Nestfile.Repository( 45 | reference: "owner/foo", 46 | version: "0.0.1", 47 | assetName: nil, 48 | checksum: nil 49 | ) 50 | ) 51 | let nestfile = Nestfile(nestPath: "./.nest", targets: [ 52 | repositoryTarget, 53 | .zip(Nestfile.ZIPURL(zipURL: zipFileURL.absoluteString, checksum: nil)) 54 | ]) 55 | #expect(controller.target(matchingTo: try #require(.parse(from: "owner/foo")), in: nestfile) == repositoryTarget) 56 | #expect(controller.target(matchingTo: try #require(.parse(from: "owner/undefined")), in: nestfile) == nil) 57 | } 58 | 59 | @Test 60 | func update() async throws { 61 | let zipFileURL = try #require(URL(string: "https://example.com/foo.artifacatbundle.zip")) 62 | let barLatestReleaseURL = try #require(URL(string: "https://api.github.com/repos/foo/bar/releases/latest")) 63 | let assetResponse = GitHubAssetResponse( 64 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 65 | tagName: "0.1.0" 66 | ) 67 | httpClient.dummyData = try [ 68 | barLatestReleaseURL: JSONEncoder().encode(assetResponse), 69 | zipFileURL: Data(contentsOf: artifactBundlePath) 70 | ] 71 | 72 | let controller = NestfileController( 73 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 74 | httpClient: httpClient, 75 | registryConfigs: nil, 76 | logger: Logger(label: "Test") 77 | ), 78 | fileSystem: fileSystem, 79 | fileDownloader: NestFileDownloader(httpClient: httpClient), 80 | checksumCalculator: SwiftChecksumCalculator(processExecutor: processExecutor) 81 | ) 82 | let nestfile = Nestfile(nestPath: "./.nest", targets: [ 83 | .repository(Nestfile.Repository( 84 | reference: "foo/bar", 85 | version: "0.0.1", 86 | assetName: nil, 87 | checksum: nil 88 | )), 89 | .zip(Nestfile.ZIPURL(zipURL: zipFileURL.absoluteString, checksum: nil)) 90 | ]) 91 | let newNestfile = try await controller.update(nestfile, excludedTargets: []) 92 | #expect(newNestfile.nestPath == nestfile.nestPath) 93 | #expect(newNestfile.targets.count == 2) 94 | #expect(newNestfile.targets == [ 95 | .repository(Nestfile.Repository( 96 | reference: "foo/bar", 97 | version: "0.1.0", 98 | assetName: "foo.artifactbundle.zip", 99 | checksum: "aaa" 100 | )), 101 | .zip(Nestfile.ZIPURL(zipURL: zipFileURL.absoluteString, checksum: "aaa")) 102 | ]) 103 | } 104 | 105 | @Test 106 | func updateWithExcludedTarget() async throws { 107 | let zipFileURL = try #require(URL(string: "https://example.com/foo.artifacatbundle.zip")) 108 | let barLatestReleaseURL = try #require(URL(string: "https://api.github.com/repos/foo/bar/releases")) 109 | let assetResponses = [ 110 | GitHubAssetResponse( 111 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 112 | tagName: "0.1.1" 113 | ), 114 | GitHubAssetResponse( 115 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 116 | tagName: "0.1.0" 117 | ), 118 | GitHubAssetResponse( 119 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 120 | tagName: "0.0.1" 121 | ) 122 | ] 123 | httpClient.dummyData = try [ 124 | barLatestReleaseURL: JSONEncoder().encode(assetResponses), 125 | zipFileURL: Data(contentsOf: artifactBundlePath) 126 | ] 127 | 128 | let controller = NestfileController( 129 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 130 | httpClient: httpClient, 131 | registryConfigs: nil, 132 | logger: Logger(label: "Test") 133 | ), 134 | fileSystem: fileSystem, 135 | fileDownloader: NestFileDownloader(httpClient: httpClient), 136 | checksumCalculator: SwiftChecksumCalculator(processExecutor: processExecutor) 137 | ) 138 | let nestfile = Nestfile(nestPath: "./.nest", targets: [ 139 | .repository(Nestfile.Repository( 140 | reference: "foo/bar", 141 | version: "0.0.1", 142 | assetName: nil, 143 | checksum: nil 144 | )), 145 | .zip(Nestfile.ZIPURL(zipURL: zipFileURL.absoluteString, checksum: nil)) 146 | ]) 147 | let newNestfile = try await controller.update(nestfile, excludedTargets: [.init(reference: "foo/bar", version: "0.1.1")]) 148 | #expect(newNestfile.nestPath == nestfile.nestPath) 149 | #expect(newNestfile.targets.count == 2) 150 | #expect(newNestfile.targets == [ 151 | .repository(Nestfile.Repository( 152 | reference: "foo/bar", 153 | version: "0.1.0", 154 | assetName: "foo.artifactbundle.zip", 155 | checksum: "aaa" 156 | )), 157 | .zip(Nestfile.ZIPURL(zipURL: zipFileURL.absoluteString, checksum: "aaa")) 158 | ]) 159 | } 160 | 161 | @Test 162 | func resolve() async throws { 163 | let zipFileURL = try #require(URL(string: "https://example.com/foo.artifacatbundle.zip")) 164 | let barReleaseURL = try #require(URL(string: "https://api.github.com/repos/foo/bar/releases/tags/0.0.1")) 165 | let buzLatestReleaseURL = try #require(URL(string: "https://api.github.com/repos/foo/buz/releases/latest")) 166 | let barAssetResponse = GitHubAssetResponse( 167 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 168 | tagName: "0.0.1" 169 | ) 170 | let buzAssetResponse = GitHubAssetResponse( 171 | assets: [GitHubAsset(name: "foo.artifactbundle.zip", browserDownloadURL: zipFileURL)], 172 | tagName: "0.1.2" 173 | ) 174 | httpClient.dummyData = try [ 175 | barReleaseURL: JSONEncoder().encode(barAssetResponse), 176 | zipFileURL: Data(contentsOf: artifactBundlePath), 177 | buzLatestReleaseURL: JSONEncoder().encode(buzAssetResponse) 178 | ] 179 | 180 | let controller = NestfileController( 181 | assetRegistryClientBuilder: AssetRegistryClientBuilder( 182 | httpClient: httpClient, 183 | registryConfigs: nil, 184 | logger: Logger(label: "Test") 185 | ), 186 | fileSystem: fileSystem, 187 | fileDownloader: NestFileDownloader(httpClient: httpClient), 188 | checksumCalculator: SwiftChecksumCalculator(processExecutor: processExecutor) 189 | ) 190 | let nestfile = Nestfile(nestPath: "./.nest", targets: [ 191 | .repository(Nestfile.Repository( 192 | reference: "foo/bar", 193 | version: "0.0.1", 194 | assetName: nil, 195 | checksum: nil 196 | )), 197 | .repository(Nestfile.Repository( 198 | reference: "foo/buz", 199 | version: nil, 200 | assetName: nil, 201 | checksum: nil 202 | )), 203 | ]) 204 | let newNestfile = try await controller.resolve(nestfile) 205 | #expect(newNestfile.nestPath == nestfile.nestPath) 206 | #expect(newNestfile.targets.count == 2) 207 | #expect(newNestfile.targets == [ 208 | .repository(Nestfile.Repository( 209 | reference: "foo/bar", 210 | version: "0.0.1", // Should not be updated in resolve case 211 | assetName: "foo.artifactbundle.zip", 212 | checksum: "aaa" 213 | )), 214 | .repository(Nestfile.Repository( 215 | reference: "foo/buz", 216 | version: "0.1.2", 217 | assetName: "foo.artifactbundle.zip", 218 | checksum: "aaa" 219 | )), 220 | ]) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Tests/NestCLITests/NestfileTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | import NestCLI 4 | import NestTestHelpers 5 | 6 | struct NestfileTests { 7 | let fileSystem = MockFileSystem( 8 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 9 | temporaryDirectory: URL(filePath: "/tmp") 10 | ) 11 | 12 | @Test 13 | func loadFile() async throws { 14 | let nestFile = """ 15 | nestPath: "aaa" 16 | targets: 17 | - reference: mtj0928/nest 18 | version: 0.1.0 19 | assetName: nest-macos.artifactbundle.zip 20 | - https://github.com/mtj0928/nest/releases/download/0.1.0/nest-macos.artifactbundle.zip 21 | """ 22 | 23 | fileSystem.item = [ 24 | "/": [ 25 | "User" : .directory, 26 | "tmp": .directory 27 | ] 28 | ] 29 | let nestFilePath = URL(filePath: "/User/nestfile") 30 | try fileSystem.write(nestFile.data(using: .utf8)!, to: nestFilePath) 31 | let nest = try Nestfile.load(from: nestFilePath.path(), fileSystem: fileSystem) 32 | #expect(nest.nestPath == "aaa") 33 | #expect(nest.targets[0] == .repository(Nestfile.Repository( 34 | reference: "mtj0928/nest", 35 | version: "0.1.0", 36 | assetName: "nest-macos.artifactbundle.zip", 37 | checksum: nil 38 | ))) 39 | #expect(nest.targets[1] == .deprecatedZIP(Nestfile.DeprecatedZIPURL( 40 | url: "https://github.com/mtj0928/nest/releases/download/0.1.0/nest-macos.artifactbundle.zip" 41 | ))) 42 | #expect(nest.registries == nil) 43 | } 44 | 45 | @Test 46 | func loadFileWithServers() async throws { 47 | let nestFile = """ 48 | nestPath: "aaa" 49 | targets: 50 | - reference: mtj0928/nest 51 | version: 0.1.0 52 | assetName: nest-macos.artifactbundle.zip 53 | - https://github.com/mtj0928/nest/releases/download/0.1.0/nest-macos.artifactbundle.zip 54 | registries: 55 | github: 56 | - host: github.com 57 | tokenEnvironmentVariable: "GH_TOKEN" 58 | - host: my-ghe.example.com 59 | tokenEnvironmentVariable: "MY_GHE_TOKEN" 60 | """ 61 | 62 | fileSystem.item = [ 63 | "/": [ 64 | "User" : .directory, 65 | "tmp": .directory 66 | ] 67 | ] 68 | let nestFilePath = URL(filePath: "/User/nestfile") 69 | try fileSystem.write(nestFile.data(using: .utf8)!, to: nestFilePath) 70 | let nest = try Nestfile.load(from: nestFilePath.path(), fileSystem: fileSystem) 71 | #expect(nest.nestPath == "aaa") 72 | #expect(nest.targets[0] == .repository(Nestfile.Repository( 73 | reference: "mtj0928/nest", 74 | version: "0.1.0", 75 | assetName: "nest-macos.artifactbundle.zip", 76 | checksum: nil 77 | ))) 78 | #expect(nest.targets[1] == .deprecatedZIP(Nestfile.DeprecatedZIPURL( 79 | url: "https://github.com/mtj0928/nest/releases/download/0.1.0/nest-macos.artifactbundle.zip" 80 | ))) 81 | let githubInfo = try #require(nest.registries?.github) 82 | #expect(githubInfo.count == 2) 83 | 84 | let githubServer = try #require(githubInfo.first { $0.host == "github.com" }) 85 | #expect(githubServer.tokenEnvironmentVariable == "GH_TOKEN") 86 | 87 | let gheServer = try #require(githubInfo.first { $0.host == "my-ghe.example.com" }) 88 | #expect(gheServer.tokenEnvironmentVariable == "MY_GHE_TOKEN") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/NestCLITests/Resources/Fixtures/foo.artifactbundle.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtj0928/nest/f5e3677d485262c84ce02b315114d240d8addfb9/Tests/NestCLITests/Resources/Fixtures/foo.artifactbundle.zip -------------------------------------------------------------------------------- /Tests/NestCLITests/Resources/Fixtures/without.artifactbundle.folder.artifactbundle.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtj0928/nest/f5e3677d485262c84ce02b315114d240d8addfb9/Tests/NestCLITests/Resources/Fixtures/without.artifactbundle.folder.artifactbundle.zip -------------------------------------------------------------------------------- /Tests/NestKitTests/ArtifactBundle/ArtifactBundleInfoTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import NestKit 4 | 5 | struct ArtifactBundleInfoTests { 6 | @Test 7 | func testParseJSON() throws { 8 | let data = try #require(json.data(using: .utf8)) 9 | let artifactBundle = try JSONDecoder().decode(ArtifactBundleInfo.self, from: data) 10 | #expect(artifactBundle == ArtifactBundleInfo(schemaVersion: "1.0", artifacts: [ 11 | "swiftlint": Artifact(version: "0.54.0", type: "executable", variants: [ 12 | ArtifactVariant( 13 | path: "swiftlint-0.54.0-macos/bin/swiftlint", 14 | supportedTriples: ["x86_64-apple-macosx", "arm64-apple-macosx"] 15 | ) 16 | ]) 17 | ])) 18 | } 19 | } 20 | 21 | private let json = """ 22 | { 23 | "schemaVersion": "1.0", 24 | "artifacts": { 25 | "swiftlint": { 26 | "version": "0.54.0", 27 | "type": "executable", 28 | "variants": [ 29 | { 30 | "path": "swiftlint-0.54.0-macos/bin/swiftlint", 31 | "supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"] 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | """ 38 | -------------------------------------------------------------------------------- /Tests/NestKitTests/ArtifactBundleManagerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NestKit 4 | import NestTestHelpers 5 | 6 | struct ArtifactBundleManagerTests { 7 | let nestDirectory = NestDirectory(rootDirectory: URL(filePath: "/User/.nest")) 8 | let fileSystem = MockFileSystem( 9 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 10 | temporaryDirectory: URL(filePath: "/User/temp") 11 | ) 12 | 13 | @Test 14 | func install() async throws { 15 | let binaryData = "binaryData".data(using: .utf8)! 16 | fileSystem.item = [ 17 | "/": [ 18 | "User": [ 19 | "Desktop": ["binary": .file(data: binaryData)] 20 | ] 21 | ] 22 | ] 23 | let manager = ArtifactBundleManager(fileSystem: fileSystem, directory: nestDirectory) 24 | let manufacturer = ExecutableManufacturer.localBuild( 25 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.0") 26 | ) 27 | let binary = ExecutableBinary( 28 | commandName: "foo", 29 | binaryPath: URL(filePath: "/User/Desktop/binary"), 30 | version: "1.0.0", 31 | manufacturer: manufacturer 32 | ) 33 | try manager.install(binary) 34 | 35 | let binaryInArtifactBundlePath = "/User/.nest/artifacts/aaa_bbb_github.com_https/1.0.0/local_build/foo" 36 | let binaryInArtifactBundle = try fileSystem.data(at: URL(filePath: binaryInArtifactBundlePath)) 37 | #expect(binaryInArtifactBundle == binaryData) 38 | 39 | let binaryInBin = try fileSystem.data(at: URL(filePath: "/User/.nest/bin/foo")) 40 | #expect(binaryInBin == binaryData) 41 | 42 | let nestInfo = manager.nestInfoController.getInfo() 43 | #expect(nestInfo.commands["foo"] == [NestInfo.Command( 44 | version: "1.0.0", 45 | binaryPath: "/artifacts/aaa_bbb_github.com_https/1.0.0/local_build/foo", 46 | resourcePaths: [], 47 | manufacturer: manufacturer 48 | )]) 49 | } 50 | 51 | @Test 52 | func uninstall() async throws { 53 | let binaryData = "binaryData".data(using: .utf8)! 54 | fileSystem.item = [ 55 | "/": [ 56 | "User": [ 57 | "Desktop": ["binary": .file(data: binaryData)] 58 | ] 59 | ] 60 | ] 61 | let manager = ArtifactBundleManager(fileSystem: fileSystem, directory: nestDirectory) 62 | let binary = ExecutableBinary( 63 | commandName: "foo", 64 | binaryPath: URL(filePath: "/User/Desktop/binary"), 65 | version: "1.0.0", 66 | manufacturer: .localBuild( 67 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.0") 68 | ) 69 | ) 70 | try manager.install(binary) 71 | try manager.uninstall(command: "foo", version: "1.0.0") 72 | 73 | let binaryInArtifactBundlePath = "/User/.nest/artifacts/aaa_bbb_github.com_https/1.0.0/local_build/foo" 74 | let binaryInArtifactBundle = try? fileSystem.data(at: URL(filePath: binaryInArtifactBundlePath)) 75 | #expect(binaryInArtifactBundle == nil) 76 | 77 | let symbolicatedBinary = try? fileSystem.data(at: URL(filePath: "/User/.nest/bin/foo")) 78 | #expect(symbolicatedBinary == nil) 79 | 80 | let nestInfo = manager.nestInfoController.getInfo() 81 | #expect(nestInfo.commands.isEmpty) 82 | } 83 | 84 | @Test 85 | func list() async throws { 86 | let binaryData = "binaryData".data(using: .utf8)! 87 | fileSystem.item = [ 88 | "/": [ 89 | "User": [ 90 | "Desktop": ["binary": .file(data: binaryData)] 91 | ] 92 | ] 93 | ] 94 | let manager = ArtifactBundleManager(fileSystem: fileSystem, directory: nestDirectory) 95 | let binaryA = ExecutableBinary( 96 | commandName: "foo", 97 | binaryPath: URL(filePath: "/User/Desktop/binary"), 98 | version: "1.0.0", 99 | manufacturer: .localBuild( 100 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.0") 101 | ) 102 | ) 103 | let binaryB = ExecutableBinary( 104 | commandName: "foo", 105 | binaryPath: URL(filePath: "/User/Desktop/binary"), 106 | version: "1.0.1", 107 | manufacturer: .localBuild( 108 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.1") 109 | ) 110 | ) 111 | try manager.install(binaryA) 112 | try manager.install(binaryB) 113 | let list = manager.list() 114 | #expect(Set(list["foo"] ?? []) == [ 115 | NestInfo.Command( 116 | version: "1.0.0", 117 | binaryPath: "/artifacts/aaa_bbb_github.com_https/1.0.0/local_build/foo", 118 | resourcePaths: [], 119 | manufacturer: .localBuild( 120 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.0") 121 | ) 122 | ), 123 | NestInfo.Command( 124 | version: "1.0.1", 125 | binaryPath: "/artifacts/aaa_bbb_github.com_https/1.0.1/local_build/foo", 126 | resourcePaths: [], 127 | manufacturer: .localBuild( 128 | repository: .init(reference: .url(URL(string: "https://github.com/aaa/bbb")!), version: "1.0.1") 129 | ) 130 | ) 131 | ]) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Tests/NestKitTests/Extensions/URLTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NestKit 4 | 5 | struct URLTests { 6 | @Test(arguments: [ 7 | (URL(string: "https://github.com/owner/repo")!, "owner/repo"), 8 | (URL(string: "https://github.com/owner/repo.git")!, "owner/repo"), 9 | (URL(string: "https://github.com/owner/repo/releases/download/0.0.1/foo.artifactbundle.zip")!, "owner/repo"), 10 | (URL(string: "https://foo.com/bar/owner/repo")!, "bar/owner"), 11 | (URL(string: "https://foo.com/bar")!, nil) 12 | ]) 13 | func testReference(url: URL, expected: String?) throws { 14 | #expect(url.reference == expected) 15 | } 16 | 17 | @Test 18 | func testNeedsUnzipForZip() throws { 19 | let url = try #require(URL(string: "artifactBundle.zip")) 20 | #expect(url.needsUnzip) 21 | } 22 | 23 | @Test 24 | func testNeedsUnzipForNonArchivedFile() throws { 25 | let url = try #require(URL(string: "artifactBundle.ipa")) 26 | #expect(!url.needsUnzip) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/NestKitTests/FileSystemItemTests.swift: -------------------------------------------------------------------------------- 1 | import NestKit 2 | import NestTestHelpers 3 | import Testing 4 | 5 | struct FileSystemItemTests { 6 | public enum DummyData { 7 | static let aTextData = "a.txt".data(using: .utf8)! 8 | static let bTextData = "b.txt".data(using: .utf8)! 9 | static let cTextData = "c".data(using: .utf8)! 10 | } 11 | 12 | let initialItem: FileSystemItem = [ 13 | "a": [ 14 | "b.txt": .file(data: DummyData.bTextData), 15 | "b": ["c": .file(data: DummyData.cTextData)] 16 | ], 17 | "a.txt": .file(data: DummyData.aTextData) 18 | ] 19 | 20 | @Test 21 | func item() throws { 22 | var item = initialItem 23 | var result = item.item(components: ["a", "b"]) 24 | #expect(result == ["c": .file(data: DummyData.cTextData)]) 25 | 26 | item = initialItem 27 | result = item.item(components: ["a", "b.txt"]) 28 | #expect(result == .file(data: DummyData.bTextData)) 29 | } 30 | 31 | @Test 32 | func remove() throws { 33 | var item = initialItem 34 | item.remove(at: ["a", "b"]) 35 | #expect(item == [ 36 | "a": ["b.txt": .file(data: DummyData.bTextData),], 37 | "a.txt": .file(data: DummyData.aTextData) 38 | ]) 39 | } 40 | 41 | @Test 42 | func update() throws { 43 | var item = initialItem 44 | item.update(item: .file(data: DummyData.aTextData), at: ["a", "b", "c-1"]) 45 | item.update(item: .file(data: DummyData.cTextData), at: ["a", "b.txt"]) 46 | #expect(item == [ 47 | "a": [ 48 | "b.txt": .file(data: DummyData.cTextData), 49 | "b": [ 50 | "c": .file(data: DummyData.cTextData), 51 | "c-1": .file(data: DummyData.aTextData) 52 | ] 53 | ], 54 | "a.txt": .file(data: DummyData.aTextData) 55 | ]) 56 | 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /Tests/NestKitTests/Git/GitHubURLBuilderTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NestKit 4 | 5 | struct GitHubURLBuilderTests { 6 | @Test(arguments:[ 7 | ( 8 | urlString: "https://github.com/owner/repo", 9 | tag: GitVersion.latestRelease, 10 | expect: "https://api.github.com/repos/owner/repo/releases/latest" 11 | ), 12 | ( 13 | urlString: "https://github.com/owner/repo", 14 | tag: .tag("1.2.3"), 15 | expect: "https://api.github.com/repos/owner/repo/releases/tags/1.2.3" 16 | ), 17 | ( 18 | urlString: "https://matsuji.net/owner/repo", 19 | tag: .latestRelease, 20 | expect: "https://matsuji.net/api/v3/repos/owner/repo/releases/latest" 21 | ), 22 | ]) 23 | func assetURL(parameter: (urlString: String, tag: GitVersion, expect: String)) throws { 24 | let url = try #require(URL(string: parameter.urlString)) 25 | let assetURL = try GitHubURLBuilder.assetURL(url, version: parameter.tag) 26 | #expect(assetURL == URL(string: parameter.expect)) 27 | } 28 | 29 | @Test(arguments: [ 30 | ( 31 | urlString: "https://github.com/owner/repo", 32 | expected: "https://api.github.com/repos/owner/repo/releases" 33 | ), 34 | ( 35 | urlString: "https://matsuji.net/owner/repo", 36 | expected: "https://matsuji.net/api/v3/repos/owner/repo/releases" 37 | ) 38 | ]) 39 | func releaseAssetURL(urlString: String, expect: String) throws { 40 | let url = try #require(URL(string: urlString)) 41 | let assetURL = try GitHubURLBuilder.releasesAssetURL(url) 42 | #expect(assetURL == URL(string: expect)) 43 | } 44 | 45 | @Test 46 | func downloadURL() throws { 47 | let url = try #require(URL(string: "https://github.com/owner/repo")) 48 | let assetURL = GitHubURLBuilder.assetDownloadURL(url, version: "0.1.0", fileName: "foo.txt") 49 | #expect(assetURL == URL(string: "https://github.com/owner/repo/releases/download/0.1.0/foo.txt")) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/NestKitTests/Git/GitURLTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NestKit 4 | 5 | struct GitURLTests { 6 | @Test(arguments:[ 7 | ( 8 | "owner/repo", 9 | expect: GitURL.url(URL(string: "https://github.com/owner/repo")!) 10 | ), 11 | ( 12 | "https://github.com/owner/repo", 13 | expect: .url(URL(string: "https://github.com/owner/repo")!) 14 | ), 15 | ( 16 | "example.com/owner/repo/foo", 17 | expect: GitURL.url(URL(string: "https://example.com/owner/repo/foo")!) 18 | ), 19 | ( 20 | "github.com/owner/repo", 21 | expect: .url(URL(string: "https://github.com/owner/repo")!) 22 | ), 23 | ( 24 | "git@github.com:owner/repo.git", 25 | expect: .ssh(SSHURL(user: "git", host: "github.com", path: "owner/repo.git")) 26 | ) 27 | ]) 28 | func parseGitURLOnSuccessCase(parameter: (argument: String, expect: GitURL)) throws { 29 | let gitURL = try #require(GitURL.parse(from: parameter.argument)) 30 | #expect(gitURL == parameter.expect) 31 | } 32 | 33 | @Test(arguments:["repo"]) 34 | func parseGitURLOnFailureCase(argument: String) throws { 35 | let repository = GitURL.parse(from: argument) 36 | #expect(repository == nil) 37 | } 38 | 39 | @Test(arguments:[ 40 | ("owner/repo", expect: "repo"), 41 | ("https://github.com/owner/repo", expect: "repo"), 42 | ("github.com/owner/repo", "repo"), 43 | ("git@github.com:owner/repo.git", expect: "repo") 44 | ]) 45 | func repositoryName(parameter: (name: String, expect: String)) { 46 | let repository = GitURL.parse(from: parameter.name) 47 | #expect(repository?.repositoryName == parameter.expect) 48 | } 49 | 50 | @Test(arguments: [ 51 | ("owner/repo", expect: "owner/repo"), 52 | ("https://github.com/owner/repo", expect: "owner/repo"), 53 | ("github.com/owner/repo", "owner/repo"), 54 | ("git@github.com:owner/repo.git", expect: "owner/repo") 55 | ]) 56 | func referenceName(name: String, expected: String) { 57 | let repository = GitURL.parse(from: name) 58 | #expect(repository?.reference == expected) 59 | } 60 | 61 | @Test(arguments:[ 62 | ("owner/repo", expect: "https://github.com/owner/repo"), 63 | ("https://github.com/owner/repo", expect: "https://github.com/owner/repo"), 64 | ("github.com/owner/repo", "https://github.com/owner/repo"), 65 | ("git@github.com:owner/repo.git", expect: "git@github.com:owner/repo.git") 66 | ]) 67 | func stringURL(parameter: (name: String, expect: String)) { 68 | let repository = GitURL.parse(from: parameter.name) 69 | #expect(repository?.stringURL == parameter.expect) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/NestKitTests/GitHub/GitHubAssetRegistryClientTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NestKit 2 | import NestTestHelpers 3 | import Foundation 4 | import HTTPTypes 5 | import Testing 6 | 7 | struct GitHubAssetRegistryClientTests { 8 | let fileSystem = MockFileSystem( 9 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 10 | temporaryDirectory: URL(filePath: "/tmp") 11 | ) 12 | 13 | @Test func fetchAssets() async throws { 14 | let repositoryURL = try #require(URL(string: "https://github.com/owner/repo")) 15 | let assetURL = try #require(URL(string: "https://example.com/foo.zip")) 16 | let httpClient = GitHubMockHTTPClient(fileSystem: fileSystem) { request in 17 | #expect(request.method == .get) 18 | #expect(request.url?.absoluteString == "https://api.github.com/repos/owner/repo/releases/tags/1.2.3") 19 | #expect(request.headerFields == [ 20 | .accept: "application/vnd.github+json", 21 | .gitHubAPIVersion: "2022-11-28" 22 | ]) 23 | let json = """ 24 | { 25 | "tag_name": "1.2.3", 26 | "assets": [ 27 | { 28 | "name": "foo.zip", 29 | "browser_download_url": "\(assetURL.absoluteString)" 30 | } 31 | ] 32 | } 33 | """ 34 | return (json.data(using: .utf8)!, HTTPResponse(status: .ok)) 35 | } 36 | let gitHubAssetRegistryClient = GitHubAssetRegistryClient( 37 | httpClient: httpClient, 38 | registryConfigs: nil, 39 | logger: .init(label: "Test") 40 | ) 41 | let assets = try await gitHubAssetRegistryClient.fetchAssets(repositoryURL: repositoryURL, version: .tag("1.2.3")) 42 | #expect(assets.assets == [Asset(fileName: "foo.zip", url: assetURL)]) 43 | } 44 | 45 | @Test(arguments: [ 46 | ("https://github.com/owner/repo", "github-com-token"), 47 | ("https://known-server.example.com/owner/repo", "known-token"), 48 | ("https://unknown-server.example.com/owner/repo", nil), 49 | ]) 50 | func fetchAssetsCanReceiveValidAuthorization(repositoryURLString: String, expectedAuthorization: String?) async throws { 51 | let repositoryURL = try #require(URL(string: repositoryURLString)) 52 | let assetURL = try #require(URL(string: "https://example.com/foo.zip")) 53 | let httpClient = GitHubMockHTTPClient(fileSystem: fileSystem) { request in 54 | if let expectedAuthorization { 55 | #expect(request.headerFields[.authorization] == "Bearer \(expectedAuthorization)") 56 | } else { 57 | #expect(request.headerFields[.authorization] == nil) 58 | } 59 | let json = """ 60 | { 61 | "tag_name": "1.2.3", 62 | "assets": [ 63 | { 64 | "name": "foo.zip", 65 | "browser_download_url": "\(assetURL.absoluteString)" 66 | } 67 | ] 68 | } 69 | """ 70 | return (json.data(using: .utf8)!, HTTPResponse(status: .ok)) 71 | } 72 | let environmentVariables = TestingEnvironmentVariables(environmentVariables: [ 73 | "GITHUB_COM_TOKEN": "github-com-token", 74 | "KNOWN_SERVER_TOKEN": "known-token", 75 | ]) 76 | let registryConfigs = GitHubRegistryConfigs.resolve( 77 | environmentVariableNames: [ 78 | "github.com": "GITHUB_COM_TOKEN", 79 | "known-server.example.com": "KNOWN_SERVER_TOKEN", 80 | ], 81 | environmentVariablesStorage: environmentVariables 82 | ) 83 | let githubRegistryClient: AssetRegistryClient = GitHubAssetRegistryClient( 84 | httpClient: httpClient, 85 | registryConfigs: registryConfigs, 86 | logger: .init(label: "Test") 87 | ) 88 | let _ = try await githubRegistryClient.fetchAssets(repositoryURL: repositoryURL, version: .tag("1.2.3")) 89 | } 90 | } 91 | 92 | struct GitHubMockHTTPClient: HTTPClient { 93 | let fileSystem: MockFileSystem 94 | let dataHandler: @Sendable (HTTPRequest) async throws -> (Data, HTTPResponse) 95 | 96 | init(fileSystem: MockFileSystem, dataHandler: @escaping @Sendable (HTTPRequest) -> (Data, HTTPResponse)) { 97 | self.fileSystem = fileSystem 98 | self.dataHandler = dataHandler 99 | } 100 | 101 | func data(for request: HTTPRequest) async throws -> (Data, HTTPResponse) { 102 | try await dataHandler(request) 103 | } 104 | 105 | func download(for request: HTTPRequest) async throws -> (URL, HTTPResponse) { 106 | let (data, response) = try await data(for: request) 107 | let fileURL = fileSystem.temporaryDirectory.appendingPathComponent(UUID().uuidString) 108 | try fileSystem.write(data, to: fileURL) 109 | return (fileURL, response) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/NestKitTests/GitHub/GitHubRepositoryNameTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import NestKit 3 | 4 | struct GitHubRepositoryNameTests { 5 | 6 | @Test(arguments: [ 7 | "foo/bar", 8 | "https://github.com/foo/bar", 9 | "https://github.com/foo/bar.git", 10 | "https://github.com/foo/bar/tree/main", 11 | "git@github.com:foo/bar.git" 12 | ]) 13 | func parseString(_ string: String) async throws { 14 | let repositoryName = try #require(GitHubRepositoryName.parse(from: string)) 15 | #expect(repositoryName == GitHubRepositoryName(owner: "foo", name: "bar")) 16 | } 17 | 18 | @Test(arguments: [ 19 | "https://example.com/foo/bar", 20 | "https://exanple.com/foo/bar.git", 21 | "https://example.com/foo/bar/tree/main", 22 | "git@example.com:foo/bar.git" 23 | ]) 24 | func parseFail(_ string: String) async throws { 25 | let repositoryName = GitHubRepositoryName.parse(from: string) 26 | #expect(repositoryName == nil) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/NestKitTests/GitHub/GitHubServerConfigsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NestKit 2 | import Foundation 3 | import Testing 4 | 5 | @Suite 6 | struct GitHubRegistryConfigsTests { 7 | struct Fixture: CustomTestStringConvertible { 8 | let testDescription: String 9 | let registryTokenEnvironmentVariableNames: [String: String] 10 | let environmentVariables: [String: String?] 11 | let expectedTokens: [String: String?] 12 | let sourceLocation: SourceLocation 13 | 14 | init(_ testDescription: String, _ registryTokenEnvironmentVariableNames: [String : String], _ environmentVariables: [String : String?], _ expectedTokens: [String : String?], sourceLocation: SourceLocation = #_sourceLocation) { 15 | self.testDescription = testDescription 16 | self.registryTokenEnvironmentVariableNames = registryTokenEnvironmentVariableNames 17 | self.environmentVariables = environmentVariables 18 | self.expectedTokens = expectedTokens 19 | self.sourceLocation = sourceLocation 20 | } 21 | } 22 | 23 | static let fixtures: [Fixture] = [ 24 | .init( 25 | "Can resolve custom tokens", 26 | ["ghe.example.com": "MY_GHE_TOKEN"], 27 | ["MY_GHE_TOKEN": "my-ghe-token"], 28 | ["ghe.example.com": "my-ghe-token"] 29 | ), 30 | .init( 31 | "Cannot resolve unknown token", 32 | ["ghe.example.com": "MY_GHE_TOKEN"], 33 | ["MY_GHE_TOKEN": "my-ghe-token"], 34 | ["unknown.example.com": nil] 35 | ), 36 | .init( 37 | "Cannot resolve GitHub.com token", 38 | ["ghe.example.com": "MY_GHE_TOKEN"], 39 | ["MY_GHE_TOKEN": "my-ghe-token"], 40 | ["github.com": nil] 41 | ), 42 | .init( 43 | "Can resolve any GitHub registry tokens from `GHE_TOKEN`", 44 | [:], 45 | ["GHE_TOKEN": "default-enterprise-token"], 46 | ["github.com": nil, "ghe.example.com": "default-enterprise-token"] 47 | ), 48 | .init( 49 | "Can overwrite GitHub.com token by the configuration", 50 | ["github.com": "OVERWRITTEN_GH_TOKEN", "ghe.example.com": "MY_GHE_TOKEN"], 51 | ["MY_GHE_TOKEN": "my-ghe-token", "GH_TOKEN": "github-com-token", "OVERWRITTEN_GH_TOKEN": "overwritten-github-com-token"], 52 | ["github.com": "overwritten-github-com-token", "ghe.example.com": "my-ghe-token"] 53 | ), 54 | ] 55 | 56 | @Test(arguments: fixtures) 57 | func resolve(fixture: Fixture) async throws { 58 | let environmentVariables = TestingEnvironmentVariables(environmentVariables: fixture.environmentVariables) 59 | let configs = GitHubRegistryConfigs.resolve( 60 | environmentVariableNames: fixture.registryTokenEnvironmentVariableNames, 61 | environmentVariablesStorage: environmentVariables 62 | ) 63 | for (host, expectedToken) in fixture.expectedTokens { 64 | let url = try #require(makeURL(from: host)) 65 | let resolvedToken = configs.config(for: url, environmentVariablesStorage: environmentVariables)?.token 66 | #expect(resolvedToken == expectedToken, "\(fixture.testDescription)", sourceLocation: fixture.sourceLocation) 67 | } 68 | } 69 | 70 | private func makeURL(from host: String) -> URL? { 71 | var components = URLComponents() 72 | components.scheme = "https" 73 | components.host = host 74 | return components.url 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Tests/NestKitTests/NestDirectoryTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NestKit 4 | 5 | struct NestDirectoryTests { 6 | let nestDirectory = NestDirectory(rootDirectory: URL(fileURLWithPath: ".nest")) 7 | 8 | @Test 9 | func nestDirectory() async throws { 10 | #expect(nestDirectory.infoJSON.relativeString == ".nest/info.json") 11 | #expect(nestDirectory.bin.relativeString == ".nest/bin") 12 | #expect(nestDirectory.artifacts.relativeString == ".nest/artifacts") 13 | } 14 | 15 | @Test 16 | func artifactsURLForArtifactBundleInRepository() throws { 17 | let artifactBundle = try ExecutableManufacturer.artifactBundle( 18 | sourceInfo: ArtifactBundleSourceInfo( 19 | zipURL: #require(URL(string: "https://example.com/foo/bar.zip")), 20 | repository: Repository( 21 | reference: .url(#require(URL(string: "https://github.com/owner/name"))), 22 | version: "0" 23 | ) 24 | ) 25 | ) 26 | #expect(nestDirectory.source(artifactBundle).relativePath == ".nest/artifacts/owner_name_github.com_https") 27 | 28 | let binaryDirectory = nestDirectory.binaryDirectory(manufacturer: artifactBundle, version: "0") 29 | #expect(binaryDirectory.relativePath == ".nest/artifacts/owner_name_github.com_https/0/bar") 30 | } 31 | 32 | @Test 33 | func artifactsURLForZIPURL() throws { 34 | let artifactBundle = try ExecutableManufacturer.artifactBundle( 35 | sourceInfo: ArtifactBundleSourceInfo( 36 | zipURL: #require(URL(string: "https://example.com/foo/bar.zip")), 37 | repository: nil 38 | ) 39 | ) 40 | #expect(nestDirectory.source(artifactBundle).relativePath == ".nest/artifacts/foo_bar.zip_example.com_https") 41 | let binaryDirectory = nestDirectory.binaryDirectory(manufacturer: artifactBundle, version: "0") 42 | #expect(binaryDirectory.relativePath == ".nest/artifacts/foo_bar.zip_example.com_https/0/bar") 43 | } 44 | 45 | @Test 46 | func artifactsURLForLocalBuild() throws { 47 | let artifactBundle = try ExecutableManufacturer.localBuild(repository: Repository( 48 | reference: .url(#require(URL(string: "https://github.com/xxx/yyy"))), 49 | version: "0" 50 | )) 51 | #expect(nestDirectory.source(artifactBundle).relativePath == ".nest/artifacts/xxx_yyy_github.com_https") 52 | let binaryDirectory = nestDirectory.binaryDirectory(manufacturer: artifactBundle, version: "0") 53 | #expect(binaryDirectory.relativePath == ".nest/artifacts/xxx_yyy_github.com_https/0/local_build") 54 | } 55 | 56 | @Test 57 | func version() throws { 58 | let artifactBundle = try ExecutableManufacturer.localBuild(repository: Repository( 59 | reference: .url(#require(URL(string: "https://github.com/xxx/yyy"))), 60 | version: "0" 61 | )) 62 | let result = nestDirectory.version(manufacturer: artifactBundle, version: "0.0.1") 63 | #expect(result.relativePath == ".nest/artifacts/xxx_yyy_github.com_https/0.0.1") 64 | } 65 | 66 | @Test 67 | func url() { 68 | #expect(nestDirectory.url("foo/bar").relativePath == ".nest/foo/bar") 69 | } 70 | 71 | @Test 72 | func symbolicPath() { 73 | #expect(nestDirectory.symbolicPath(name: "foo").relativePath == ".nest/bin/foo") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/NestKitTests/NestFileDownloaderTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | import NestTestHelpers 4 | @testable import NestKit 5 | 6 | struct NestFileDownloaderTests { 7 | let mockFileSystem = MockFileSystem( 8 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 9 | temporaryDirectory: URL(filePath: "/tmp") 10 | ) 11 | 12 | init () { 13 | mockFileSystem.item = [ 14 | "/": [ 15 | "User": [:], 16 | "tmp": [:] 17 | ] 18 | ] 19 | } 20 | 21 | @Test 22 | func download() async throws { 23 | let httpClient = MockHTTPClient(mockFileSystem: mockFileSystem) 24 | let binary = try #require("foo".data(using: .utf8)) 25 | let url = try #require(URL(string: "https://example.com/artifactbundle")) 26 | httpClient.dummyData[url] = binary 27 | let nestFileDownloader = NestFileDownloader(httpClient: httpClient) 28 | let downloadedURL = try await nestFileDownloader.download(url: url) 29 | try #expect(mockFileSystem.data(at: downloadedURL) == binary) 30 | } 31 | 32 | @Test 33 | func downloadButNotFound() async throws { 34 | let httpClient = MockHTTPClient(mockFileSystem: mockFileSystem) 35 | let url = try #require(URL(string: "https://example.com/artifactbundle")) 36 | let nestFileDownloader = NestFileDownloader(httpClient: httpClient) 37 | await #expect(throws: FileDownloaderError.notFound(url: url)) { 38 | try await nestFileDownloader.download(url: url) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/NestKitTests/NestInfoControllerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import NestKit 3 | import Testing 4 | import NestTestHelpers 5 | 6 | struct NestInfoControllerTests { 7 | let nestDirectory = NestDirectory(rootDirectory: URL(filePath: "/User/.nest")) 8 | let mockFileSystem = MockFileSystem( 9 | homeDirectoryForCurrentUser: URL(filePath: "/User"), 10 | temporaryDirectory: URL(filePath: "/tmp") 11 | ) 12 | 13 | init() { 14 | mockFileSystem.item = [ 15 | "/": [ 16 | "User": [ 17 | ".nest": [:] 18 | ] 19 | ] 20 | ] 21 | } 22 | 23 | @Test 24 | func add() throws { 25 | let nestInfoController = NestInfoController(directory: nestDirectory, fileSystem: mockFileSystem) 26 | let command = try NestInfo.Command( 27 | version: "0.0.1", 28 | binaryPath: "a", 29 | resourcePaths: ["b", "c"], 30 | manufacturer: .artifactBundle( 31 | sourceInfo: ArtifactBundleSourceInfo( 32 | zipURL: #require(URL(string: "https://foo.com/bar.zip")), 33 | repository: Repository( 34 | reference: #require(.parse(from: "foo/bar")), 35 | version: "0.0.1" 36 | ) 37 | ) 38 | ) 39 | ) 40 | try nestInfoController.add(name: "foo", command: command) 41 | #expect(nestInfoController.getInfo() == NestInfo(version: "1", commands: ["foo": [command]])) 42 | } 43 | 44 | @Test 45 | func remove() throws { 46 | let nestInfoController = NestInfoController(directory: nestDirectory, fileSystem: mockFileSystem) 47 | let command = try NestInfo.Command( 48 | version: "0.0.1", 49 | binaryPath: "a", 50 | resourcePaths: ["b", "c"], 51 | manufacturer: .artifactBundle( 52 | sourceInfo: ArtifactBundleSourceInfo( 53 | zipURL: #require(URL(string: "https://foo.com/bar.zip")), 54 | repository: Repository( 55 | reference: #require(.parse(from: "foo/bar")), 56 | version: "0.0.1" 57 | ) 58 | ) 59 | ) 60 | ) 61 | try nestInfoController.add(name: "foo", command: command) 62 | #expect(nestInfoController.getInfo() == NestInfo(version: "1", commands: ["foo": [command]])) 63 | 64 | try nestInfoController.remove(command: "bar", version: "0.0.1") 65 | try nestInfoController.remove(command: "foo", version: "1.2.1") 66 | #expect(nestInfoController.getInfo() == NestInfo(version: "1", commands: ["foo": [command]])) 67 | 68 | try nestInfoController.remove(command: "foo", version: "0.0.1") 69 | #expect(nestInfoController.getInfo() == NestInfo(version: "1", commands: [:])) 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Tests/NestKitTests/Swift/SwiftPackageTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import NestKit 4 | import NestTestHelpers 5 | 6 | struct SwiftPackageTests { 7 | 8 | @Test 9 | func executableFile() async throws { 10 | let swiftPackage = SwiftPackage( 11 | at: URL(filePath: "/User"), 12 | executorBuilder: MockExecutorBuilder { command, arguments in 13 | "" 14 | } 15 | ) 16 | let executableFilePath = swiftPackage.executableFile(name: "foo") 17 | #expect(executableFilePath.path() == "/User/.build/release/foo") 18 | } 19 | 20 | @Test 21 | func buildForRelease() async throws { 22 | let swiftPackage = SwiftPackage( 23 | at: URL(filePath: "/User"), 24 | executorBuilder: MockExecutorBuilder(dummy: [ 25 | "/usr/bin/which swift": "/usr/bin/swift", 26 | "/usr/bin/swift build -c release": "success" 27 | ]) 28 | ) 29 | try await swiftPackage.buildForRelease() 30 | } 31 | 32 | @Test 33 | func description() async throws { 34 | let swiftPackage = SwiftPackage( 35 | at: URL(filePath: "/User"), 36 | executorBuilder: MockExecutorBuilder(dummy: [ 37 | "/usr/bin/which swift": "/usr/bin/swift", 38 | "/usr/bin/swift package describe --type json": """ 39 | { 40 | "products": [ 41 | { 42 | "name": "foo", 43 | "type": { 44 | "executable" : null 45 | } 46 | }, 47 | { 48 | "name": "bar", 49 | "type" : { 50 | "library" : [ 51 | "automatic" 52 | ] 53 | } 54 | }, 55 | ] 56 | } 57 | """ 58 | ]) 59 | ) 60 | let packageDescription = try await swiftPackage.description() 61 | #expect(packageDescription.executableNames == ["foo"]) 62 | #expect(packageDescription.products == [ 63 | SwiftPackageDescription.Product(name: "foo", type: .executable), 64 | SwiftPackageDescription.Product(name: "bar", type: .library(.automatic)) 65 | ]) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/NestKitTests/TestingEnvironmentVariables.swift: -------------------------------------------------------------------------------- 1 | import NestKit 2 | 3 | struct TestingEnvironmentVariables: EnvironmentVariableStorage { 4 | private var environmentVariables: [String: String] 5 | 6 | init(environmentVariables: [String : String?]) { 7 | self.environmentVariables = environmentVariables.compactMapValues { $0 } 8 | } 9 | 10 | subscript(_ key: String) -> String? { 11 | environmentVariables[key] 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /Tests/NestKitTests/TripleDetectorTests.swift: -------------------------------------------------------------------------------- 1 | import NestKit 2 | import Testing 3 | import Foundation 4 | import NestTestHelpers 5 | 6 | struct TripleDetectorTests { 7 | @Test 8 | func detect() async throws { 9 | let swiftCommand = SwiftCommand(executor: MockProcessExecutor(dummy: [ 10 | "/usr/bin/which swift": "/usr/bin/swift", 11 | "/usr/bin/swift -print-target-info": """ 12 | { 13 | "target": { 14 | "unversionedTriple": "arm64-apple-macosx", 15 | } 16 | } 17 | """ 18 | ])) 19 | let detector = TripleDetector(swiftCommand: swiftCommand) 20 | #expect(try await detector.detect() == "arm64-apple-macosx") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/NestTests/Arguments/ExcludedTarget+ArgumentsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NestKit 2 | import Testing 3 | 4 | struct ExcludedTargetTests { 5 | @Test( 6 | arguments: [ 7 | ( 8 | argument: "owner/repo", 9 | expected: ExcludedTarget(reference: "owner/repo", version: nil) 10 | ), 11 | ( 12 | argument: "owner/repo@0.0.1", 13 | expected: ExcludedTarget(reference: "owner/repo", version: "0.0.1") 14 | ), 15 | ( 16 | argument: "foo@owner/repo@0.0.1", 17 | expected: nil 18 | ) 19 | ] 20 | ) 21 | func parse(argument: String, expected: ExcludedTarget?) async throws { 22 | #expect(ExcludedTarget(argument: argument) == expected) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/NestTests/Arguments/InstallTargetTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import nest 4 | @testable import NestKit 5 | 6 | struct InstallTargetTests { 7 | @Test 8 | func testInstallTarget() throws { 9 | let installTarget = InstallTarget(argument: "artifactBundle.zip") 10 | guard case .artifactBundle(let url) = installTarget else { 11 | Issue.record("installTarget needs to be artifactBundle.") 12 | return 13 | } 14 | #expect(url == URL(string: "artifactBundle.zip")) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/NestTests/Arguments/SubCommandOfRunCommandTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import nest 3 | 4 | struct SubCommandOfRunCommandTests { 5 | @Test(arguments: [ 6 | (["owner/repo"], "owner/repo", []), 7 | (["owner/repo", "--option"], "owner/repo", ["--option"]), 8 | (["owner/repo", "subcommand", "--option"], "owner/repo", ["subcommand", "--option"]) 9 | ]) 10 | func initialize(arguments: [String], expectedReference reference: String, expectedSubcommands subcommands: [String]?) throws { 11 | let executor = try SubCommandOfRunCommand(arguments: arguments) 12 | #expect(executor.repository.reference == reference) 13 | #expect(executor.arguments == subcommands) 14 | } 15 | 16 | @Test(arguments: [ 17 | ([], SubCommandOfRunCommand.ParseError.emptyArguments), 18 | ([""], .invalidFormat), 19 | (["ownerrepo"], .invalidFormat) 20 | ]) 21 | func failedInitialize(arguments: [String], expectedError error: SubCommandOfRunCommand.ParseError) throws { 22 | #expect(throws: error, performing: { 23 | try SubCommandOfRunCommand(arguments: arguments) 24 | }) 25 | } 26 | } 27 | --------------------------------------------------------------------------------